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

3
.gitignore vendored
View File

@@ -40,3 +40,6 @@ node_modules/
# Docker image tarball # Docker image tarball
image.tar image.tar
# Binaries
bin/

View File

@@ -12,7 +12,11 @@ RUN go mod download
COPY . . COPY . .
# Compile SCSS to CSS # Compile SCSS to CSS
RUN sass internal/web/assets/scss/main.scss internal/web/assets/css/output.css --style=compressed # Install Sass
RUN npm install -g sass
# Compile SCSS
RUN sass web/assets/scss/main.scss web/assets/css/output.css --style=compressed
RUN CGO_ENABLED=1 GOOS=linux go build -o main cmd/api/main.go RUN CGO_ENABLED=1 GOOS=linux go build -o main cmd/api/main.go

View File

@@ -6,9 +6,9 @@
# Variables # Variables
APP_NAME := billit APP_NAME := billit
MAIN_PATH := ./cmd/api MAIN_PATH := ./cmd/server
SCSS_DIR := internal/web/assets/scss SCSS_DIR := web/assets/scss
CSS_DIR := internal/web/assets/css CSS_DIR := web/assets/css
# Default target # Default target
all: scss build all: scss build

View File

@@ -10,6 +10,7 @@ import (
"time" "time"
"billit/internal/server" "billit/internal/server"
"billit/web"
) )
func gracefulShutdown(apiServer *http.Server, done chan bool) { func gracefulShutdown(apiServer *http.Server, done chan bool) {
@@ -39,7 +40,7 @@ func gracefulShutdown(apiServer *http.Server, done chan bool) {
func main() { func main() {
server := server.NewServer() server := server.NewServer(web.Files)
// Create a done channel to signal when the shutdown is complete // Create a done channel to signal when the shutdown is complete
done := make(chan bool, 1) done := make(chan bool, 1)
@@ -47,6 +48,7 @@ func main() {
// Run graceful shutdown in a separate goroutine // Run graceful shutdown in a separate goroutine
go gracefulShutdown(server, done) go gracefulShutdown(server, done)
log.Printf("Server is starting on %s", server.Addr)
err := server.ListenAndServe() err := server.ListenAndServe()
if err != nil && err != http.ErrServerClosed { if err != nil && err != http.ErrServerClosed {
panic(fmt.Sprintf("http server error: %s", err)) panic(fmt.Sprintf("http server error: %s", err))

BIN
db/dev.db

Binary file not shown.

3
go.mod
View File

@@ -7,10 +7,11 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/labstack/echo/v4 v4.13.4 github.com/labstack/echo/v4 v4.13.4
github.com/mattn/go-sqlite3 v1.14.32
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
) )
require github.com/mattn/go-sqlite3 v1.14.32
require ( require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/labstack/gommon v0.4.2 // indirect github.com/labstack/gommon v0.4.2 // indirect

View File

@@ -1,28 +0,0 @@
package api
import (
"billit/internal/database"
"net/http"
"github.com/labstack/echo/v4"
)
// Handlers holds dependencies for API handlers
type Handlers struct {
db database.Service
}
// NewHandlers creates API handlers with db access
func NewHandlers(db database.Service) *Handlers {
return &Handlers{db: db}
}
// HealthHandler returns the health status
func (h *Handlers) HealthHandler(c echo.Context) error {
return c.JSON(http.StatusOK, h.db.Health())
}
// Note: Product and Invoice API endpoints are disabled.
// All operations go through the authenticated web UI.
// To re-enable API access, add API authentication and update these handlers
// to accept userID from authenticated API requests.

View File

@@ -1,69 +0,0 @@
package auth
import (
"billit/internal/database"
)
// DBUserStore adapts database.Service to auth.UserStore interface
type DBUserStore struct {
db database.Service
}
// NewDBUserStore creates a new user store backed by the database
func NewDBUserStore(db database.Service) *DBUserStore {
return &DBUserStore{db: db}
}
// CreateUser creates a new user
func (s *DBUserStore) CreateUser(email, passwordHash string) (*User, error) {
dbUser, err := s.db.CreateUser(email, passwordHash)
if err != nil {
return nil, err
}
return &User{
ID: dbUser.ID,
Email: dbUser.Email,
Password: dbUser.Password,
CompanyDetails: dbUser.CompanyDetails,
BankDetails: dbUser.BankDetails,
CreatedAt: dbUser.CreatedAt,
}, nil
}
// GetUserByEmail retrieves a user by email
func (s *DBUserStore) GetUserByEmail(email string) (*User, error) {
dbUser, err := s.db.GetUserByEmail(email)
if err != nil {
return nil, err
}
if dbUser == nil {
return nil, nil
}
return &User{
ID: dbUser.ID,
Email: dbUser.Email,
Password: dbUser.Password,
CompanyDetails: dbUser.CompanyDetails,
BankDetails: dbUser.BankDetails,
CreatedAt: dbUser.CreatedAt,
}, nil
}
// GetUserByID retrieves a user by ID
func (s *DBUserStore) GetUserByID(id string) (*User, error) {
dbUser, err := s.db.GetUserByID(id)
if err != nil {
return nil, err
}
if dbUser == nil {
return nil, nil
}
return &User{
ID: dbUser.ID,
Email: dbUser.Email,
Password: dbUser.Password,
CompanyDetails: dbUser.CompanyDetails,
BankDetails: dbUser.BankDetails,
CreatedAt: dbUser.CreatedAt,
}, nil
}

View File

@@ -0,0 +1,63 @@
package database
import (
"billit/internal/models"
"database/sql"
"fmt"
"time"
)
// CreateBuyerDetails creates a new buyer details entry
func (s *service) CreateBuyerDetails(userID string, name string, details string) (*models.BuyerDetails, error) {
id := fmt.Sprintf("%d", time.Now().UnixNano())
_, err := s.db.Exec(`INSERT INTO buyer_details (id, user_id, name, details) VALUES (?, ?, ?, ?)`, id, userID, name, details)
if err != nil {
return nil, err
}
return s.GetBuyerDetails(id, userID)
}
// UpdateBuyerDetails updates an existing buyer details entry
func (s *service) UpdateBuyerDetails(id string, userID string, name string, details string) error {
_, err := s.db.Exec(`UPDATE buyer_details SET name = ?, details = ? WHERE id = ? AND user_id = ?`, name, details, id, userID)
return err
}
// GetBuyerDetails retrieves a buyer details entry by ID
func (s *service) GetBuyerDetails(id string, userID string) (*models.BuyerDetails, error) {
var b models.BuyerDetails
err := s.db.QueryRow(`SELECT id, user_id, name, details, created_at FROM buyer_details WHERE id = ? AND user_id = ?`, id, userID).
Scan(&b.ID, &b.UserID, &b.Name, &b.Details, &b.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &b, nil
}
// GetAllBuyerDetails retrieves all buyer details for a user
func (s *service) GetAllBuyerDetails(userID string) ([]models.BuyerDetails, error) {
rows, err := s.db.Query(`SELECT id, user_id, name, details, created_at FROM buyer_details WHERE user_id = ? ORDER BY name`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var buyers []models.BuyerDetails
for rows.Next() {
var b models.BuyerDetails
if err := rows.Scan(&b.ID, &b.UserID, &b.Name, &b.Details, &b.CreatedAt); err != nil {
return nil, err
}
buyers = append(buyers, b)
}
return buyers, nil
}
// DeleteBuyerDetails removes a buyer details entry
func (s *service) DeleteBuyerDetails(id string, userID string) error {
_, err := s.db.Exec(`DELETE FROM buyer_details WHERE id = ? AND user_id = ?`, id, userID)
return err
}

View File

@@ -1,569 +0,0 @@
package database
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"time"
_ "github.com/joho/godotenv/autoload"
_ "github.com/mattn/go-sqlite3"
)
// Product represents a product in the database
type Product struct {
SKU string `json:"sku"`
Name string `json:"name"`
HSNCode string `json:"hsn_code"`
BasePrice float64 `json:"base_price"`
WholesalePrice float64 `json:"wholesale_price"`
GSTRate float64 `json:"gst_rate"`
SmallOrderQty int `json:"small_order_qty"`
SmallOrderFee float64 `json:"small_order_fee"` // Convenience fee for orders below SmallOrderQty
Unit string `json:"unit"` // Unit of measurement (e.g., "pcs", "kg", "box")
UserID string `json:"user_id"`
CreatedAt string `json:"created_at"`
}
// Invoice represents a stored invoice
type Invoice struct {
ID string `json:"id"` // UUID
HumanReadableID string `json:"human_readable_id"` // Formatted ID like INV/12-2025/001
Data string `json:"data"` // JSON blob of invoice details
UserID string `json:"user_id"`
CreatedAt string `json:"created_at"`
}
// User represents an authenticated user
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Password string `json:"-"`
CompanyDetails string `json:"company_details"` // Multiline company details for invoice header
BankDetails string `json:"bank_details"` // Multiline bank details for invoice footer
InvoicePrefix string `json:"invoice_prefix"` // Prefix for invoice IDs (e.g., INV, BILL)
InvoiceCounter int `json:"invoice_counter"` // Auto-incrementing counter for invoice serial numbers
CreatedAt string `json:"created_at"`
}
// BuyerDetails represents a buyer/customer for invoices
type BuyerDetails struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"` // Display name for selection
Details string `json:"details"` // Multiline buyer details
CreatedAt string `json:"created_at"`
}
// Service represents a service that interacts with a database.
// Service represents a service that interacts with a database.
type Service interface {
// Health returns a map of health status information.
Health() map[string]string
// Close terminates the database connection.
Close() error
// Product operations (user-scoped)
CreateProduct(p Product, userID string) error
UpdateProduct(p Product, userID string) error
GetAllProducts(userID string) ([]Product, error)
GetProductBySKU(sku string, userID string) (*Product, error)
DeleteProduct(sku string, userID string) error
// Invoice operations (user-scoped)
CreateInvoice(id string, humanReadableID string, data interface{}, userID string) error
GetInvoice(id string, userID string) (*Invoice, error)
GetAllInvoices(userID string) ([]Invoice, error)
GetRecentProducts(userID string, limit int) ([]Product, error)
GetRecentInvoices(userID string, limit int) ([]Invoice, error)
GetNextInvoiceNumber(userID string) (string, error) // Returns formatted invoice ID and increments counter
// User operations
CreateUser(email, passwordHash string) (*User, error)
GetUserByEmail(email string) (*User, error)
GetUserByID(id string) (*User, error)
UpdateUserPassword(id string, passwordHash string) error
UpdateUserDetails(id string, companyDetails string, bankDetails string, invoicePrefix string) error
// Buyer details operations
CreateBuyerDetails(userID string, name string, details string) (*BuyerDetails, error)
UpdateBuyerDetails(id string, userID string, name string, details string) error
GetBuyerDetails(id string, userID string) (*BuyerDetails, error)
GetAllBuyerDetails(userID string) ([]BuyerDetails, error)
DeleteBuyerDetails(id string, userID string) error
}
type service struct {
db *sql.DB
}
var (
dburl = os.Getenv("DB_PATH")
dbInstance *service
)
func New() Service {
// Reuse Connection
if dbInstance != nil {
return dbInstance
}
// Ensure the directory for the database file exists
if dburl != "" && dburl != ":memory:" {
dir := filepath.Dir(dburl)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
log.Fatalf("Failed to create database directory: %v", err)
}
}
}
db, err := sql.Open("sqlite3", dburl)
if err != nil {
log.Fatal(err)
}
dbInstance = &service{
db: db,
}
// Initialize tables
dbInstance.initTables()
return dbInstance
}
func (s *service) initTables() {
// Products table with user ownership
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS products (
sku TEXT NOT NULL,
name TEXT NOT NULL,
hsn_code TEXT,
base_price REAL NOT NULL,
wholesale_price REAL,
gst_rate REAL NOT NULL DEFAULT 0.18,
small_order_qty INTEGER DEFAULT 1,
small_order_fee REAL DEFAULT 0,
unit TEXT DEFAULT 'pcs',
user_id TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (sku, user_id)
)
`)
if err != nil {
log.Printf("Error creating products table: %v", err)
}
// Add user_id column if not exists (migration for existing DBs)
s.db.Exec(`ALTER TABLE products ADD COLUMN user_id TEXT DEFAULT ''`)
// Add unit column if not exists (migration for existing DBs)
s.db.Exec(`ALTER TABLE products ADD COLUMN unit TEXT DEFAULT 'pcs'`)
// Invoices table with user ownership
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS invoices (
id TEXT PRIMARY KEY,
human_readable_id TEXT DEFAULT '',
data TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
log.Printf("Error creating invoices table: %v", err)
}
// Add columns if not exists (migration for existing DBs)
s.db.Exec(`ALTER TABLE invoices ADD COLUMN user_id TEXT DEFAULT ''`)
s.db.Exec(`ALTER TABLE invoices ADD COLUMN human_readable_id TEXT DEFAULT ''`)
// Create index on user_id for fast lookups
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_products_user ON products(user_id)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_invoices_user ON invoices(user_id)`)
// Users table
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
company_details TEXT DEFAULT '',
bank_details TEXT DEFAULT '',
invoice_prefix TEXT DEFAULT 'INV',
invoice_counter INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
log.Printf("Error creating users table: %v", err)
}
// Add columns if not exists (migration for existing DBs)
s.db.Exec(`ALTER TABLE users ADD COLUMN company_details TEXT DEFAULT ''`)
s.db.Exec(`ALTER TABLE users ADD COLUMN bank_details TEXT DEFAULT ''`)
s.db.Exec(`ALTER TABLE users ADD COLUMN invoice_prefix TEXT DEFAULT 'INV'`)
s.db.Exec(`ALTER TABLE users ADD COLUMN invoice_counter INTEGER DEFAULT 0`)
s.db.Exec(`ALTER TABLE users ADD COLUMN invoice_prefix TEXT DEFAULT 'INV'`)
s.db.Exec(`ALTER TABLE users ADD COLUMN invoice_counter INTEGER DEFAULT 0`)
// Create index on email for fast lookups
_, err = s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`)
if err != nil {
log.Printf("Error creating users email index: %v", err)
}
// Buyer details table
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS buyer_details (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
details TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
log.Printf("Error creating buyer_details table: %v", err)
}
// Create index on user_id for fast lookups
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_buyer_details_user ON buyer_details(user_id)`)
}
// CreateProduct inserts a new product for a user
func (s *service) CreateProduct(p Product, userID string) error {
_, err := s.db.Exec(`
INSERT INTO products (sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, p.SKU, p.Name, p.HSNCode, p.BasePrice, p.WholesalePrice, p.GSTRate, p.SmallOrderQty, p.SmallOrderFee, p.Unit, userID)
return err
}
// UpdateProduct updates an existing product for a user
func (s *service) UpdateProduct(p Product, userID string) error {
_, err := s.db.Exec(`
UPDATE products SET name=?, hsn_code=?, base_price=?, wholesale_price=?, gst_rate=?, small_order_qty=?, small_order_fee=?, unit=?
WHERE sku=? AND user_id=?
`, p.Name, p.HSNCode, p.BasePrice, p.WholesalePrice, p.GSTRate, p.SmallOrderQty, p.SmallOrderFee, p.Unit, p.SKU, userID)
return err
}
// GetAllProducts returns all products for a user
func (s *service) GetAllProducts(userID string) ([]Product, error) {
rows, err := s.db.Query(`SELECT sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id, created_at FROM products WHERE user_id=? ORDER BY name`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var products []Product
for rows.Next() {
var p Product
if err := rows.Scan(&p.SKU, &p.Name, &p.HSNCode, &p.BasePrice, &p.WholesalePrice, &p.GSTRate, &p.SmallOrderQty, &p.SmallOrderFee, &p.Unit, &p.UserID, &p.CreatedAt); err != nil {
return nil, err
}
products = append(products, p)
}
return products, nil
}
// GetProductBySKU returns a single product by SKU for a user
func (s *service) GetProductBySKU(sku string, userID string) (*Product, error) {
var p Product
err := s.db.QueryRow(`SELECT sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id, created_at FROM products WHERE sku=? AND user_id=?`, sku, userID).
Scan(&p.SKU, &p.Name, &p.HSNCode, &p.BasePrice, &p.WholesalePrice, &p.GSTRate, &p.SmallOrderQty, &p.SmallOrderFee, &p.Unit, &p.UserID, &p.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &p, nil
}
// DeleteProduct removes a product by SKU for a user
func (s *service) DeleteProduct(sku string, userID string) error {
_, err := s.db.Exec(`DELETE FROM products WHERE sku=? AND user_id=?`, sku, userID)
return err
}
// GetNextInvoiceNumber generates the next invoice ID in format PREFIX/MMM-YYYY/XXX and increments the counter
func (s *service) GetNextInvoiceNumber(userID string) (string, error) {
var prefix string
var counter int
// Get current prefix and counter
err := s.db.QueryRow(`SELECT COALESCE(invoice_prefix, 'INV'), COALESCE(invoice_counter, 0) FROM users WHERE id = ?`, userID).
Scan(&prefix, &counter)
if err != nil {
return "", err
}
// Increment counter
counter++
// Update counter in database
_, err = s.db.Exec(`UPDATE users SET invoice_counter = ? WHERE id = ?`, counter, userID)
if err != nil {
return "", err
}
// Generate formatted invoice ID: PREFIX/MMM-YYYY/XXX
now := time.Now()
humanReadableID := fmt.Sprintf("%s/%s-%d/%03d", prefix, now.Month().String()[:3], now.Year(), counter)
return humanReadableID, nil
}
// CreateInvoice stores an invoice with UUID and human-readable ID for a user
func (s *service) CreateInvoice(id string, humanReadableID string, data interface{}, userID string) error {
jsonData, err := json.Marshal(data)
if err != nil {
return err
}
_, err = s.db.Exec(`INSERT INTO invoices (id, human_readable_id, data, user_id) VALUES (?, ?, ?, ?)`, id, humanReadableID, string(jsonData), userID)
return err
}
// GetInvoice retrieves an invoice by ID for a user
func (s *service) GetInvoice(id string, userID string) (*Invoice, error) {
var inv Invoice
err := s.db.QueryRow(`SELECT id, COALESCE(human_readable_id, ''), data, user_id, created_at FROM invoices WHERE id=? AND user_id=?`, id, userID).
Scan(&inv.ID, &inv.HumanReadableID, &inv.Data, &inv.UserID, &inv.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &inv, nil
}
// GetAllInvoices retrieves all invoices for a user
func (s *service) GetAllInvoices(userID string) ([]Invoice, error) {
rows, err := s.db.Query(`SELECT id, COALESCE(human_readable_id, ''), data, user_id, created_at FROM invoices WHERE user_id=? ORDER BY created_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var invoices []Invoice
for rows.Next() {
var inv Invoice
if err := rows.Scan(&inv.ID, &inv.HumanReadableID, &inv.Data, &inv.UserID, &inv.CreatedAt); err != nil {
return nil, err
}
invoices = append(invoices, inv)
}
return invoices, nil
}
// GetRecentProducts returns the most recently added products for a user
func (s *service) GetRecentProducts(userID string, limit int) ([]Product, error) {
rows, err := s.db.Query(`SELECT sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id, created_at FROM products WHERE user_id=? ORDER BY created_at DESC LIMIT ?`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var products []Product
for rows.Next() {
var p Product
if err := rows.Scan(&p.SKU, &p.Name, &p.HSNCode, &p.BasePrice, &p.WholesalePrice, &p.GSTRate, &p.SmallOrderQty, &p.SmallOrderFee, &p.Unit, &p.UserID, &p.CreatedAt); err != nil {
return nil, err
}
products = append(products, p)
}
return products, nil
}
// GetRecentInvoices returns the most recently generated invoices for a user
func (s *service) GetRecentInvoices(userID string, limit int) ([]Invoice, error) {
rows, err := s.db.Query(`SELECT id, COALESCE(human_readable_id, ''), data, user_id, created_at FROM invoices WHERE user_id=? ORDER BY created_at DESC LIMIT ?`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var invoices []Invoice
for rows.Next() {
var inv Invoice
if err := rows.Scan(&inv.ID, &inv.HumanReadableID, &inv.Data, &inv.UserID, &inv.CreatedAt); err != nil {
return nil, err
}
invoices = append(invoices, inv)
}
return invoices, nil
}
// CreateUser creates a new user with hashed password
func (s *service) CreateUser(email, passwordHash string) (*User, error) {
id := fmt.Sprintf("%d", time.Now().UnixNano())
_, err := s.db.Exec(`INSERT INTO users (id, email, password) VALUES (?, ?, ?)`, id, email, passwordHash)
if err != nil {
return nil, err
}
return s.GetUserByID(id)
}
// GetUserByEmail retrieves a user by email
func (s *service) GetUserByEmail(email string) (*User, error) {
var u User
err := s.db.QueryRow(`SELECT id, email, password, COALESCE(company_details, ''), COALESCE(bank_details, ''), COALESCE(invoice_prefix, 'INV'), COALESCE(invoice_counter, 0), created_at FROM users WHERE email = ?`, email).
Scan(&u.ID, &u.Email, &u.Password, &u.CompanyDetails, &u.BankDetails, &u.InvoicePrefix, &u.InvoiceCounter, &u.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &u, nil
}
// GetUserByID retrieves a user by ID
func (s *service) GetUserByID(id string) (*User, error) {
var u User
err := s.db.QueryRow(`SELECT id, email, password, COALESCE(company_details, ''), COALESCE(bank_details, ''), COALESCE(invoice_prefix, 'INV'), COALESCE(invoice_counter, 0), created_at FROM users WHERE id = ?`, id).
Scan(&u.ID, &u.Email, &u.Password, &u.CompanyDetails, &u.BankDetails, &u.InvoicePrefix, &u.InvoiceCounter, &u.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &u, nil
}
// UpdateUserPassword updates a user's password hash
func (s *service) UpdateUserPassword(id string, passwordHash string) error {
_, err := s.db.Exec(`UPDATE users SET password = ? WHERE id = ?`, passwordHash, id)
return err
}
// UpdateUserDetails updates a user's company and bank details
func (s *service) UpdateUserDetails(id string, companyDetails string, bankDetails string, invoicePrefix string) error {
_, err := s.db.Exec(`UPDATE users SET company_details = ?, bank_details = ?, invoice_prefix = ? WHERE id = ?`, companyDetails, bankDetails, invoicePrefix, id)
return err
}
// CreateBuyerDetails creates a new buyer details entry
func (s *service) CreateBuyerDetails(userID string, name string, details string) (*BuyerDetails, error) {
id := fmt.Sprintf("%d", time.Now().UnixNano())
_, err := s.db.Exec(`INSERT INTO buyer_details (id, user_id, name, details) VALUES (?, ?, ?, ?)`, id, userID, name, details)
if err != nil {
return nil, err
}
return s.GetBuyerDetails(id, userID)
}
// UpdateBuyerDetails updates an existing buyer details entry
func (s *service) UpdateBuyerDetails(id string, userID string, name string, details string) error {
_, err := s.db.Exec(`UPDATE buyer_details SET name = ?, details = ? WHERE id = ? AND user_id = ?`, name, details, id, userID)
return err
}
// GetBuyerDetails retrieves a buyer details entry by ID
func (s *service) GetBuyerDetails(id string, userID string) (*BuyerDetails, error) {
var b BuyerDetails
err := s.db.QueryRow(`SELECT id, user_id, name, details, created_at FROM buyer_details WHERE id = ? AND user_id = ?`, id, userID).
Scan(&b.ID, &b.UserID, &b.Name, &b.Details, &b.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &b, nil
}
// GetAllBuyerDetails retrieves all buyer details for a user
func (s *service) GetAllBuyerDetails(userID string) ([]BuyerDetails, error) {
rows, err := s.db.Query(`SELECT id, user_id, name, details, created_at FROM buyer_details WHERE user_id = ? ORDER BY name`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var buyers []BuyerDetails
for rows.Next() {
var b BuyerDetails
if err := rows.Scan(&b.ID, &b.UserID, &b.Name, &b.Details, &b.CreatedAt); err != nil {
return nil, err
}
buyers = append(buyers, b)
}
return buyers, nil
}
// DeleteBuyerDetails removes a buyer details entry
func (s *service) DeleteBuyerDetails(id string, userID string) error {
_, err := s.db.Exec(`DELETE FROM buyer_details WHERE id = ? AND user_id = ?`, id, userID)
return err
}
// Health checks the health of the database connection by pinging the database.
// It returns a map with keys indicating various health statistics.
func (s *service) Health() map[string]string {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
stats := make(map[string]string)
// Ping the database
err := s.db.PingContext(ctx)
if err != nil {
stats["status"] = "down"
stats["error"] = fmt.Sprintf("db down: %v", err)
log.Fatalf("db down: %v", err) // Log the error and terminate the program
return stats
}
// Database is up, add more statistics
stats["status"] = "up"
stats["message"] = "It's healthy"
// Get database stats (like open connections, in use, idle, etc.)
dbStats := s.db.Stats()
stats["open_connections"] = strconv.Itoa(dbStats.OpenConnections)
stats["in_use"] = strconv.Itoa(dbStats.InUse)
stats["idle"] = strconv.Itoa(dbStats.Idle)
stats["wait_count"] = strconv.FormatInt(dbStats.WaitCount, 10)
stats["wait_duration"] = dbStats.WaitDuration.String()
stats["max_idle_closed"] = strconv.FormatInt(dbStats.MaxIdleClosed, 10)
stats["max_lifetime_closed"] = strconv.FormatInt(dbStats.MaxLifetimeClosed, 10)
// Evaluate stats to provide a health message
if dbStats.OpenConnections > 40 { // Assuming 50 is the max for this example
stats["message"] = "The database is experiencing heavy load."
}
if dbStats.WaitCount > 1000 {
stats["message"] = "The database has a high number of wait events, indicating potential bottlenecks."
}
if dbStats.MaxIdleClosed > int64(dbStats.OpenConnections)/2 {
stats["message"] = "Many idle connections are being closed, consider revising the connection pool settings."
}
if dbStats.MaxLifetimeClosed > int64(dbStats.OpenConnections)/2 {
stats["message"] = "Many connections are being closed due to max lifetime, consider increasing max lifetime or revising the connection usage pattern."
}
return stats
}
// Close closes the database connection.
// It logs a message indicating the disconnection from the specific database.
// If the connection is successfully closed, it returns nil.
// If an error occurs while closing the connection, it returns the error.
func (s *service) Close() error {
log.Printf("Disconnected from database: %s", dburl)
return s.db.Close()
}

View File

@@ -0,0 +1,99 @@
package database
import (
"billit/internal/models"
"database/sql"
"encoding/json"
"fmt"
"time"
)
// GetNextInvoiceNumber generates the next invoice ID in format PREFIX/MMM-YYYY/XXX and increments the counter
func (s *service) GetNextInvoiceNumber(userID string) (string, error) {
var prefix string
var counter int
// Get current prefix and counter
err := s.db.QueryRow(`SELECT COALESCE(invoice_prefix, 'INV'), COALESCE(invoice_counter, 0) FROM users WHERE id = ?`, userID).
Scan(&prefix, &counter)
if err != nil {
return "", err
}
// Increment counter
counter++
// Update counter in database
_, err = s.db.Exec(`UPDATE users SET invoice_counter = ? WHERE id = ?`, counter, userID)
if err != nil {
return "", err
}
// Generate formatted invoice ID: PREFIX/MMM-YYYY/XXX
now := time.Now()
humanReadableID := fmt.Sprintf("%s/%s-%d/%03d", prefix, now.Month().String()[:3], now.Year(), counter)
return humanReadableID, nil
}
// CreateInvoice stores an invoice with UUID and human-readable ID for a user
func (s *service) CreateInvoice(id string, humanReadableID string, data interface{}, userID string) error {
jsonData, err := json.Marshal(data)
if err != nil {
return err
}
_, err = s.db.Exec(`INSERT INTO invoices (id, human_readable_id, data, user_id) VALUES (?, ?, ?, ?)`, id, humanReadableID, string(jsonData), userID)
return err
}
// GetInvoice retrieves an invoice by ID for a user
func (s *service) GetInvoice(id string, userID string) (*models.Invoice, error) {
var inv models.Invoice
err := s.db.QueryRow(`SELECT id, COALESCE(human_readable_id, ''), data, user_id, created_at FROM invoices WHERE id=? AND user_id=?`, id, userID).
Scan(&inv.ID, &inv.HumanReadableID, &inv.Data, &inv.UserID, &inv.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &inv, nil
}
// GetAllInvoices retrieves all invoices for a user
func (s *service) GetAllInvoices(userID string) ([]models.Invoice, error) {
rows, err := s.db.Query(`SELECT id, COALESCE(human_readable_id, ''), data, user_id, created_at FROM invoices WHERE user_id=? ORDER BY created_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var invoices []models.Invoice
for rows.Next() {
var inv models.Invoice
if err := rows.Scan(&inv.ID, &inv.HumanReadableID, &inv.Data, &inv.UserID, &inv.CreatedAt); err != nil {
return nil, err
}
invoices = append(invoices, inv)
}
return invoices, nil
}
// GetRecentInvoices returns the most recently generated invoices for a user
func (s *service) GetRecentInvoices(userID string, limit int) ([]models.Invoice, error) {
rows, err := s.db.Query(`SELECT id, COALESCE(human_readable_id, ''), data, user_id, created_at FROM invoices WHERE user_id=? ORDER BY created_at DESC LIMIT ?`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var invoices []models.Invoice
for rows.Next() {
var inv models.Invoice
if err := rows.Scan(&inv.ID, &inv.HumanReadableID, &inv.Data, &inv.UserID, &inv.CreatedAt); err != nil {
return nil, err
}
invoices = append(invoices, inv)
}
return invoices, nil
}

View File

@@ -0,0 +1,82 @@
package database
import (
"billit/internal/models"
"database/sql"
)
// CreateProduct inserts a new product for a user
func (s *service) CreateProduct(p models.Product, userID string) error {
_, err := s.db.Exec(`
INSERT INTO products (sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, p.SKU, p.Name, p.HSNCode, p.BasePrice, p.WholesalePrice, p.GSTRate, p.SmallOrderQty, p.SmallOrderFee, p.Unit, userID)
return err
}
// UpdateProduct updates an existing product for a user
func (s *service) UpdateProduct(p models.Product, userID string) error {
_, err := s.db.Exec(`
UPDATE products SET name=?, hsn_code=?, base_price=?, wholesale_price=?, gst_rate=?, small_order_qty=?, small_order_fee=?, unit=?
WHERE sku=? AND user_id=?
`, p.Name, p.HSNCode, p.BasePrice, p.WholesalePrice, p.GSTRate, p.SmallOrderQty, p.SmallOrderFee, p.Unit, p.SKU, userID)
return err
}
// GetAllProducts returns all products for a user
func (s *service) GetAllProducts(userID string) ([]models.Product, error) {
rows, err := s.db.Query(`SELECT sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id, created_at FROM products WHERE user_id=? ORDER BY name`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var products []models.Product
for rows.Next() {
var p models.Product
if err := rows.Scan(&p.SKU, &p.Name, &p.HSNCode, &p.BasePrice, &p.WholesalePrice, &p.GSTRate, &p.SmallOrderQty, &p.SmallOrderFee, &p.Unit, &p.UserID, &p.CreatedAt); err != nil {
return nil, err
}
products = append(products, p)
}
return products, nil
}
// GetProductBySKU returns a single product by SKU for a user
func (s *service) GetProductBySKU(sku string, userID string) (*models.Product, error) {
var p models.Product
err := s.db.QueryRow(`SELECT sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id, created_at FROM products WHERE sku=? AND user_id=?`, sku, userID).
Scan(&p.SKU, &p.Name, &p.HSNCode, &p.BasePrice, &p.WholesalePrice, &p.GSTRate, &p.SmallOrderQty, &p.SmallOrderFee, &p.Unit, &p.UserID, &p.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &p, nil
}
// DeleteProduct removes a product by SKU for a user
func (s *service) DeleteProduct(sku string, userID string) error {
_, err := s.db.Exec(`DELETE FROM products WHERE sku=? AND user_id=?`, sku, userID)
return err
}
// GetRecentProducts returns the most recently added products for a user
func (s *service) GetRecentProducts(userID string, limit int) ([]models.Product, error) {
rows, err := s.db.Query(`SELECT sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id, created_at FROM products WHERE user_id=? ORDER BY created_at DESC LIMIT ?`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var products []models.Product
for rows.Next() {
var p models.Product
if err := rows.Scan(&p.SKU, &p.Name, &p.HSNCode, &p.BasePrice, &p.WholesalePrice, &p.GSTRate, &p.SmallOrderQty, &p.SmallOrderFee, &p.Unit, &p.UserID, &p.CreatedAt); err != nil {
return nil, err
}
products = append(products, p)
}
return products, nil
}

View File

@@ -0,0 +1,141 @@
package database
import (
"billit/internal/models"
"context"
"database/sql"
"fmt"
"log"
"os"
"strconv"
"time"
_ "github.com/mattn/go-sqlite3"
)
// Service represents the database service interface
type Service interface {
// Health returns database health status
Health() map[string]string
// Close closes the database connection
Close() error
// Buyer methods
CreateBuyerDetails(userID string, name string, details string) (*models.BuyerDetails, error)
UpdateBuyerDetails(id string, userID string, name string, details string) error
GetBuyerDetails(id string, userID string) (*models.BuyerDetails, error)
GetAllBuyerDetails(userID string) ([]models.BuyerDetails, error)
DeleteBuyerDetails(id string, userID string) error
// User methods
CreateUser(email, passwordHash string) (*models.User, error)
GetUserByEmail(email string) (*models.User, error)
GetUserByID(id string) (*models.User, error)
UpdateUserPassword(id string, passwordHash string) error
UpdateUserDetails(id string, companyDetails string, bankDetails string, invoicePrefix string) error
// Invoice methods
GetNextInvoiceNumber(userID string) (string, error)
CreateInvoice(id string, humanReadableID string, data interface{}, userID string) error
GetInvoice(id string, userID string) (*models.Invoice, error)
GetAllInvoices(userID string) ([]models.Invoice, error)
GetRecentInvoices(userID string, limit int) ([]models.Invoice, error)
// Product methods
CreateProduct(p models.Product, userID string) error
UpdateProduct(p models.Product, userID string) error
GetAllProducts(userID string) ([]models.Product, error)
GetProductBySKU(sku string, userID string) (*models.Product, error)
DeleteProduct(sku string, userID string) error
GetRecentProducts(userID string, limit int) ([]models.Product, error)
}
type service struct {
db *sql.DB
}
var dbInstance *service
// New creates a new database service
func New() Service {
// Reuse connection if already established
if dbInstance != nil {
return dbInstance
}
dbPath := os.Getenv("DB_PATH")
if dbPath == "" {
dbPath = "./db/dev.db"
}
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
log.Fatal(err)
}
// Check connection
if err := db.Ping(); err != nil {
log.Fatal("Could not ping database:", err)
}
dbInstance = &service{
db: db,
}
return dbInstance
}
// Health checks the health of the database connection
func (s *service) Health() map[string]string {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
stats := make(map[string]string)
// Ping the database
err := s.db.PingContext(ctx)
if err != nil {
stats["status"] = "down"
stats["error"] = fmt.Sprintf("db down: %v", err)
log.Printf("db down: %v", err)
return stats
}
// Database is up, add more statistics
stats["status"] = "up"
stats["message"] = "It's healthy"
// Get database stats
dbStats := s.db.Stats()
stats["open_connections"] = strconv.Itoa(dbStats.OpenConnections)
stats["in_use"] = strconv.Itoa(dbStats.InUse)
stats["idle"] = strconv.Itoa(dbStats.Idle)
stats["wait_count"] = strconv.FormatInt(dbStats.WaitCount, 10)
stats["wait_duration"] = dbStats.WaitDuration.String()
stats["max_idle_closed"] = strconv.FormatInt(dbStats.MaxIdleClosed, 10)
stats["max_lifetime_closed"] = strconv.FormatInt(dbStats.MaxLifetimeClosed, 10)
if dbStats.OpenConnections > 40 {
stats["message"] = "The database is experiencing heavy load."
}
if dbStats.WaitCount > 1000 {
stats["message"] = "The database has a high number of wait events, indicating potential bottlenecks."
}
if dbStats.MaxIdleClosed > int64(dbStats.OpenConnections)/2 {
stats["message"] = "Many idle connections are being closed, consider revising the connection pool settings."
}
if dbStats.MaxLifetimeClosed > int64(dbStats.OpenConnections)/2 {
stats["message"] = "Many connections are being closed due to max lifetime, consider increasing max lifetime or revising connection usage."
}
return stats
}
// Close closes the database connection
func (s *service) Close() error {
return s.db.Close()
}

58
internal/database/user.go Normal file
View File

@@ -0,0 +1,58 @@
package database
import (
"billit/internal/models"
"database/sql"
"fmt"
"time"
)
// CreateUser creates a new user with hashed password
func (s *service) CreateUser(email, passwordHash string) (*models.User, error) {
id := fmt.Sprintf("%d", time.Now().UnixNano())
_, err := s.db.Exec(`INSERT INTO users (id, email, password) VALUES (?, ?, ?)`, id, email, passwordHash)
if err != nil {
return nil, err
}
return s.GetUserByID(id)
}
// GetUserByEmail retrieves a user by email
func (s *service) GetUserByEmail(email string) (*models.User, error) {
var u models.User
err := s.db.QueryRow(`SELECT id, email, password, COALESCE(company_details, ''), COALESCE(bank_details, ''), COALESCE(invoice_prefix, 'INV'), COALESCE(invoice_counter, 0), created_at FROM users WHERE email = ?`, email).
Scan(&u.ID, &u.Email, &u.Password, &u.CompanyDetails, &u.BankDetails, &u.InvoicePrefix, &u.InvoiceCounter, &u.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &u, nil
}
// GetUserByID retrieves a user by ID
func (s *service) GetUserByID(id string) (*models.User, error) {
var u models.User
err := s.db.QueryRow(`SELECT id, email, password, COALESCE(company_details, ''), COALESCE(bank_details, ''), COALESCE(invoice_prefix, 'INV'), COALESCE(invoice_counter, 0), created_at FROM users WHERE id = ?`, id).
Scan(&u.ID, &u.Email, &u.Password, &u.CompanyDetails, &u.BankDetails, &u.InvoicePrefix, &u.InvoiceCounter, &u.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &u, nil
}
// UpdateUserPassword updates a user's password hash
func (s *service) UpdateUserPassword(id string, passwordHash string) error {
_, err := s.db.Exec(`UPDATE users SET password = ? WHERE id = ?`, passwordHash, id)
return err
}
// UpdateUserDetails updates a user's company and bank details
func (s *service) UpdateUserDetails(id string, companyDetails string, bankDetails string, invoicePrefix string) error {
_, err := s.db.Exec(`UPDATE users SET company_details = ?, bank_details = ?, invoice_prefix = ? WHERE id = ?`, companyDetails, bankDetails, invoicePrefix, id)
return err
}

View File

@@ -1,122 +0,0 @@
package gst
import (
"testing"
)
func TestCalculateLineItem(t *testing.T) {
c := NewCalculator()
product := Product{
SKU: "TEST01",
BasePrice: 100,
WholesalePrice: 90,
GSTRate: Rate18,
}
// Case 1: Retail, Intra-state
item := c.CalculateLineItem(product, 2, CustomerRetail, false)
if item.UnitPrice != 100 {
t.Errorf("Expected UnitPrice 100, got %f", item.UnitPrice)
}
if item.TaxableVal != 200 {
t.Errorf("Expected TaxableVal 200, got %f", item.TaxableVal)
}
if item.CGSTAmount != 18 { // 9% of 200
t.Errorf("Expected CGST 18, got %f", item.CGSTAmount)
}
if item.IGSTAmount != 0 {
t.Errorf("Expected IGST 0, got %f", item.IGSTAmount)
}
if item.TotalAmount != 236 { // 200 + 18 + 18
t.Errorf("Expected Total 236, got %f", item.TotalAmount)
}
// Case 2: Wholesale, Inter-state
item = c.CalculateLineItem(product, 10, CustomerWholesale, true)
if item.UnitPrice != 90 {
t.Errorf("Expected UnitPrice 90, got %f", item.UnitPrice)
}
if item.TaxableVal != 900 {
t.Errorf("Expected TaxableVal 900, got %f", item.TaxableVal)
}
if item.CGSTAmount != 0 {
t.Errorf("Expected CGST 0, got %f", item.CGSTAmount)
}
if item.IGSTAmount != 162 { // 18% of 900
t.Errorf("Expected IGST 162, got %f", item.IGSTAmount)
}
if item.TotalAmount != 1062 { // 900 + 162
t.Errorf("Expected Total 1062, got %f", item.TotalAmount)
}
}
func TestCalculateInvoice(t *testing.T) {
c := NewCalculator()
product := Product{SKU: "TEST01", BasePrice: 100, GSTRate: Rate18}
item1 := c.CalculateLineItem(product, 1, CustomerRetail, false)
item2 := c.CalculateLineItem(product, 1, CustomerRetail, false)
// Test with convenience fee (intra-state)
invoice := c.CalculateInvoice([]LineItem{item1, item2}, 50, false)
// Convenience fee is taxed at 18%
// SubTotal should include convenience fee: 100 + 100 + 50 = 250
expectedSubTotal := 250.0
if invoice.SubTotal != expectedSubTotal {
t.Errorf("Expected SubTotal %f, got %f", expectedSubTotal, invoice.SubTotal)
}
// Convenience fee tax: 50 * 0.18 = 9
expectedFeeTax := 9.0
if invoice.ConvenienceFeeTax != expectedFeeTax {
t.Errorf("Expected ConvenienceFeeTax %f, got %f", expectedFeeTax, invoice.ConvenienceFeeTax)
}
// Total CGST: 9 + 9 + 4.5 = 22.5 (from items + half of fee tax)
expectedCGST := 22.5
if invoice.TotalCGST != expectedCGST {
t.Errorf("Expected TotalCGST %f, got %f", expectedCGST, invoice.TotalCGST)
}
// GrandTotal: SubTotal + CGST + SGST = 250 + 22.5 + 22.5 = 295
expectedTotal := 295.0
if invoice.GrandTotal != expectedTotal {
t.Errorf("Expected GrandTotal %f, got %f", expectedTotal, invoice.GrandTotal)
}
}
func TestCalculateInvoiceInterState(t *testing.T) {
c := NewCalculator()
product := Product{SKU: "TEST01", BasePrice: 100, GSTRate: Rate18}
item := c.CalculateLineItem(product, 1, CustomerRetail, true)
// Test with convenience fee (inter-state)
invoice := c.CalculateInvoice([]LineItem{item}, 50, true)
// SubTotal: 100 + 50 = 150
expectedSubTotal := 150.0
if invoice.SubTotal != expectedSubTotal {
t.Errorf("Expected SubTotal %f, got %f", expectedSubTotal, invoice.SubTotal)
}
// Convenience fee tax: 50 * 0.18 = 9
expectedFeeTax := 9.0
if invoice.ConvenienceFeeTax != expectedFeeTax {
t.Errorf("Expected ConvenienceFeeTax %f, got %f", expectedFeeTax, invoice.ConvenienceFeeTax)
}
// Total IGST: 18 + 9 = 27 (from item + fee tax)
expectedIGST := 27.0
if invoice.TotalIGST != expectedIGST {
t.Errorf("Expected TotalIGST %f, got %f", expectedIGST, invoice.TotalIGST)
}
// GrandTotal: SubTotal + IGST = 150 + 27 = 177
expectedTotal := 177.0
if invoice.GrandTotal != expectedTotal {
t.Errorf("Expected GrandTotal %f, got %f", expectedTotal, invoice.GrandTotal)
}
}

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

View File

@@ -1,24 +1,62 @@
package web package handler
import ( import (
"billit/internal/auth" "billit/internal/logic"
"billit/internal/view"
"net/http" "net/http"
"net/url" "net/url"
"os"
"strings" "strings"
"time"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
// AuthHandlers holds auth service reference // AuthHandlers holds auth service reference
type AuthHandlers struct { type AuthHandlers struct {
auth *auth.Service auth *logic.AuthService
} }
// NewAuthHandlers creates handlers with auth service // NewAuthHandlers creates handlers with auth service
func NewAuthHandlers(authService *auth.Service) *AuthHandlers { func NewAuthHandlers(authService *logic.AuthService) *AuthHandlers {
return &AuthHandlers{auth: authService} 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) // LoginPageHandler renders the login page (home page)
func (h *AuthHandlers) LoginPageHandler(c echo.Context) error { func (h *AuthHandlers) LoginPageHandler(c echo.Context) error {
// Check if already logged in // Check if already logged in
@@ -32,7 +70,7 @@ func (h *AuthHandlers) LoginPageHandler(c echo.Context) error {
} }
// Capture redirect URL from query param // Capture redirect URL from query param
redirectURL := c.QueryParam("redirect") redirectURL := c.QueryParam("redirect")
return Render(c, LoginPage("", "", redirectURL)) return view.Render(c, view.LoginPage("", "", redirectURL))
} }
// LoginHandler handles login form submission // LoginHandler handles login form submission
@@ -42,16 +80,16 @@ func (h *AuthHandlers) LoginHandler(c echo.Context) error {
redirectURL := c.FormValue("redirect") redirectURL := c.FormValue("redirect")
if email == "" || password == "" { if email == "" || password == "" {
return Render(c, LoginPage("Email and password are required", email, redirectURL)) return view.Render(c, view.LoginPage("Email and password are required", email, redirectURL))
} }
token, err := h.auth.Login(email, password) token, err := h.auth.Login(email, password)
if err != nil { if err != nil {
return Render(c, LoginPage("Invalid email or password", email, redirectURL)) return view.Render(c, view.LoginPage("Invalid email or password", email, redirectURL))
} }
// Set HTTP-only cookie // Set HTTP-only cookie
cookie := h.auth.CreateAuthCookie(token) cookie := createAuthCookie(token)
c.SetCookie(cookie) c.SetCookie(cookie)
// Redirect to original URL or home page // Redirect to original URL or home page
@@ -63,7 +101,7 @@ func (h *AuthHandlers) LoginHandler(c echo.Context) error {
// RegisterPageHandler renders the registration page // RegisterPageHandler renders the registration page
func (h *AuthHandlers) RegisterPageHandler(c echo.Context) error { func (h *AuthHandlers) RegisterPageHandler(c echo.Context) error {
return Render(c, RegisterPage("", "")) return view.Render(c, view.RegisterPage("", ""))
} }
// RegisterHandler handles registration form submission // RegisterHandler handles registration form submission
@@ -73,23 +111,23 @@ func (h *AuthHandlers) RegisterHandler(c echo.Context) error {
confirmPassword := c.FormValue("confirm_password") confirmPassword := c.FormValue("confirm_password")
if email == "" || password == "" { if email == "" || password == "" {
return Render(c, RegisterPage("Email and password are required", email)) return view.Render(c, view.RegisterPage("Email and password are required", email))
} }
if password != confirmPassword { if password != confirmPassword {
return Render(c, RegisterPage("Passwords do not match", email)) return view.Render(c, view.RegisterPage("Passwords do not match", email))
} }
if len(password) < 8 { if len(password) < 8 {
return Render(c, RegisterPage("Password must be at least 8 characters", email)) return view.Render(c, view.RegisterPage("Password must be at least 8 characters", email))
} }
_, err := h.auth.Register(email, password) _, err := h.auth.Register(email, password)
if err != nil { if err != nil {
if err == auth.ErrUserExists { if err == logic.ErrUserExists {
return Render(c, RegisterPage("An account with this email already exists", email)) return view.Render(c, view.RegisterPage("An account with this email already exists", email))
} }
return Render(c, RegisterPage(err.Error(), email)) return view.Render(c, view.RegisterPage(err.Error(), email))
} }
// Auto-login after registration // Auto-login after registration
@@ -98,7 +136,7 @@ func (h *AuthHandlers) RegisterHandler(c echo.Context) error {
return c.Redirect(http.StatusFound, "/") return c.Redirect(http.StatusFound, "/")
} }
cookie := h.auth.CreateAuthCookie(token) cookie := createAuthCookie(token)
c.SetCookie(cookie) c.SetCookie(cookie)
return c.Redirect(http.StatusFound, "/home") return c.Redirect(http.StatusFound, "/home")
@@ -106,7 +144,7 @@ func (h *AuthHandlers) RegisterHandler(c echo.Context) error {
// LogoutHandler clears the auth cookie and redirects to login // LogoutHandler clears the auth cookie and redirects to login
func (h *AuthHandlers) LogoutHandler(c echo.Context) error { func (h *AuthHandlers) LogoutHandler(c echo.Context) error {
cookie := h.auth.ClearAuthCookie() cookie := clearAuthCookie()
c.SetCookie(cookie) c.SetCookie(cookie)
return c.Redirect(http.StatusFound, "/") return c.Redirect(http.StatusFound, "/")
} }
@@ -124,9 +162,9 @@ func (h *AuthHandlers) AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
claims, err := h.auth.ValidateToken(cookie.Value) claims, err := h.auth.ValidateToken(cookie.Value)
if err != nil { if err != nil {
// Invalid/expired token - show session expired dialog // Invalid/expired token - show session expired dialog
c.SetCookie(h.auth.ClearAuthCookie()) c.SetCookie(clearAuthCookie())
redirectPath := url.QueryEscape(c.Request().URL.RequestURI()) redirectPath := url.QueryEscape(c.Request().URL.RequestURI())
return Render(c, SessionExpiredPage(redirectPath)) return view.Render(c, view.SessionExpiredPage(redirectPath))
} }
// Store user info in context // Store user info in context
@@ -135,4 +173,4 @@ func (h *AuthHandlers) AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return next(c) return next(c)
} }
} }

View File

@@ -1,8 +1,10 @@
package web package handler
import "billit/internal/models"
import "billit/internal/view"
import ( import (
"billit/internal/database" "billit/internal/database"
"billit/internal/gst" "billit/internal/logic"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -98,13 +100,13 @@ func (h *BillingHandlers) BillingPageHandler(c echo.Context) error {
userID := getUserID(c) userID := getUserID(c)
products, err := h.db.GetAllProducts(userID) products, err := h.db.GetAllProducts(userID)
if err != nil { if err != nil {
products = []database.Product{} products = []models.Product{}
} }
buyers, err := h.db.GetAllBuyerDetails(userID) buyers, err := h.db.GetAllBuyerDetails(userID)
if err != nil { if err != nil {
buyers = []database.BuyerDetails{} buyers = []models.BuyerDetails{}
} }
return Render(c, BillingPage(products, buyers)) return view.Render(c, view.BillingPage(products, buyers))
} }
// CalculateBillHandler calculates the bill (HTMX endpoint) // CalculateBillHandler calculates the bill (HTMX endpoint)
@@ -114,14 +116,14 @@ func (h *BillingHandlers) CalculateBillHandler(c echo.Context) error {
regionType := c.FormValue("region_type") regionType := c.FormValue("region_type")
includeConvenienceFee := c.FormValue("include_convenience_fee") == "yes" includeConvenienceFee := c.FormValue("include_convenience_fee") == "yes"
cType := gst.CustomerRetail cType := logic.CustomerRetail
if customerType == "wholesale" { if customerType == "wholesale" {
cType = gst.CustomerWholesale cType = logic.CustomerWholesale
} }
isInterState := regionType == "inter" isInterState := regionType == "inter"
calculator := gst.NewCalculator() calculator := logic.NewCalculator()
var items []gst.LineItem var items []logic.LineItem
var totalFee float64 var totalFee float64
// Support up to 50 product slots for dynamic adding // Support up to 50 product slots for dynamic adding
@@ -144,14 +146,14 @@ func (h *BillingHandlers) CalculateBillHandler(c echo.Context) error {
continue continue
} }
// Convert to gst.Product // Convert to logic.Product
product := gst.Product{ product := logic.Product{
SKU: dbProduct.SKU, SKU: dbProduct.SKU,
Name: dbProduct.Name, Name: dbProduct.Name,
HSNCode: dbProduct.HSNCode, HSNCode: dbProduct.HSNCode,
BasePrice: dbProduct.BasePrice, BasePrice: dbProduct.BasePrice,
WholesalePrice: dbProduct.WholesalePrice, WholesalePrice: dbProduct.WholesalePrice,
GSTRate: gst.Rate(dbProduct.GSTRate), GSTRate: logic.Rate(dbProduct.GSTRate),
SmallOrderQty: dbProduct.SmallOrderQty, SmallOrderQty: dbProduct.SmallOrderQty,
SmallOrderFee: dbProduct.SmallOrderFee, SmallOrderFee: dbProduct.SmallOrderFee,
Unit: dbProduct.Unit, Unit: dbProduct.Unit,
@@ -169,7 +171,7 @@ func (h *BillingHandlers) CalculateBillHandler(c echo.Context) error {
invoice := calculator.CalculateInvoice(items, totalFee, isInterState) invoice := calculator.CalculateInvoice(items, totalFee, isInterState)
invoice.CustomerType = cType invoice.CustomerType = cType
return Render(c, InvoiceSummary(invoice)) return view.Render(c, view.InvoiceSummary(invoice))
} }
// GenerateBillHandler generates final invoice with UUID and persists to DB // GenerateBillHandler generates final invoice with UUID and persists to DB
@@ -184,14 +186,14 @@ func (h *BillingHandlers) GenerateBillHandler(c echo.Context) error {
buyerID := c.FormValue("buyer_id") buyerID := c.FormValue("buyer_id")
includeConvenienceFee := c.FormValue("include_convenience_fee") == "yes" includeConvenienceFee := c.FormValue("include_convenience_fee") == "yes"
cType := gst.CustomerRetail cType := logic.CustomerRetail
if customerType == "wholesale" { if customerType == "wholesale" {
cType = gst.CustomerWholesale cType = logic.CustomerWholesale
} }
isInterState := regionType == "inter" isInterState := regionType == "inter"
calculator := gst.NewCalculator() calculator := logic.NewCalculator()
var items []gst.LineItem var items []logic.LineItem
var totalFee float64 var totalFee float64
for i := 0; i < 50; i++ { for i := 0; i < 50; i++ {
@@ -212,13 +214,13 @@ func (h *BillingHandlers) GenerateBillHandler(c echo.Context) error {
continue continue
} }
product := gst.Product{ product := logic.Product{
SKU: dbProduct.SKU, SKU: dbProduct.SKU,
Name: dbProduct.Name, Name: dbProduct.Name,
HSNCode: dbProduct.HSNCode, HSNCode: dbProduct.HSNCode,
BasePrice: dbProduct.BasePrice, BasePrice: dbProduct.BasePrice,
WholesalePrice: dbProduct.WholesalePrice, WholesalePrice: dbProduct.WholesalePrice,
GSTRate: gst.Rate(dbProduct.GSTRate), GSTRate: logic.Rate(dbProduct.GSTRate),
SmallOrderQty: dbProduct.SmallOrderQty, SmallOrderQty: dbProduct.SmallOrderQty,
SmallOrderFee: dbProduct.SmallOrderFee, SmallOrderFee: dbProduct.SmallOrderFee,
Unit: dbProduct.Unit, Unit: dbProduct.Unit,
@@ -286,11 +288,11 @@ func (h *BillingHandlers) ShowInvoiceHandler(c echo.Context) error {
inv, err := h.db.GetInvoice(invoiceID, userID) inv, err := h.db.GetInvoice(invoiceID, userID)
if err != nil || inv == nil { if err != nil || inv == nil {
return RenderNotFound(c, "Invoice not found or you don't have access to it.") return view.RenderNotFound(c, "Invoice not found or you don't have access to it.")
} }
// Parse the JSON data back into Invoice struct // Parse the JSON data back into Invoice struct
var invoice gst.Invoice var invoice logic.Invoice
if err := json.Unmarshal([]byte(inv.Data), &invoice); err != nil { if err := json.Unmarshal([]byte(inv.Data), &invoice); err != nil {
return c.String(http.StatusInternalServerError, "failed to parse invoice data") return c.String(http.StatusInternalServerError, "failed to parse invoice data")
} }
@@ -345,7 +347,7 @@ func (h *BillingHandlers) ShowInvoiceHandler(c echo.Context) error {
fmt.Fprintf(w, `<div class="invoice-details-block" style="margin-bottom:1rem;"><strong>From:</strong><br>%s</div>`, invoice.CompanyDetails) fmt.Fprintf(w, `<div class="invoice-details-block" style="margin-bottom:1rem;"><strong>From:</strong><br>%s</div>`, invoice.CompanyDetails)
} }
if err := PrintableInvoice(invoice).Render(c.Request().Context(), w); err != nil { if err := view.PrintableInvoice(invoice).Render(c.Request().Context(), w); err != nil {
return err return err
} }

View File

@@ -1,5 +1,6 @@
package web package handler
import "billit/internal/view"
import ( import (
"billit/internal/database" "billit/internal/database"
"net/http" "net/http"
@@ -22,14 +23,14 @@ func (h *BuyerHandlers) BuyerListHandler(c echo.Context) error {
userID := getUserID(c) userID := getUserID(c)
buyers, err := h.db.GetAllBuyerDetails(userID) buyers, err := h.db.GetAllBuyerDetails(userID)
if err != nil { if err != nil {
return RenderServerError(c, "Failed to load buyers. Please try again.") return view.RenderServerError(c, "Failed to load buyers. Please try again.")
} }
return Render(c, BuyerListPage(buyers)) return view.Render(c, view.BuyerListPage(buyers))
} }
// BuyerCreatePageHandler renders the /buyer/create form page // BuyerCreatePageHandler renders the /buyer/create form page
func (h *BuyerHandlers) BuyerCreatePageHandler(c echo.Context) error { func (h *BuyerHandlers) BuyerCreatePageHandler(c echo.Context) error {
return Render(c, BuyerCreatePage()) return view.Render(c, view.BuyerCreatePage())
} }
// BuyerEditPageHandler renders the /buyer/edit/:id form page // BuyerEditPageHandler renders the /buyer/edit/:id form page
@@ -39,9 +40,9 @@ func (h *BuyerHandlers) BuyerEditPageHandler(c echo.Context) error {
buyer, err := h.db.GetBuyerDetails(id, userID) buyer, err := h.db.GetBuyerDetails(id, userID)
if err != nil || buyer == nil { if err != nil || buyer == nil {
return RenderNotFound(c, "Buyer not found or you don't have access to it.") return view.RenderNotFound(c, "Buyer not found or you don't have access to it.")
} }
return Render(c, BuyerEditPage(*buyer)) return view.Render(c, view.BuyerEditPage(*buyer))
} }
// BuyerCreateHandler handles POST /buyer/create // BuyerCreateHandler handles POST /buyer/create

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

View File

@@ -1,5 +1,7 @@
package web package handler
import "billit/internal/models"
import "billit/internal/view"
import ( import (
"billit/internal/database" "billit/internal/database"
@@ -24,14 +26,14 @@ func (h *HomeHandlers) HomePageHandler(c echo.Context) error {
// Get recent products (last 5) // Get recent products (last 5)
recentProducts, err := h.db.GetRecentProducts(userID, 5) recentProducts, err := h.db.GetRecentProducts(userID, 5)
if err != nil { if err != nil {
recentProducts = []database.Product{} recentProducts = []models.Product{}
} }
// Get recent invoices (last 5) // Get recent invoices (last 5)
recentInvoices, err := h.db.GetRecentInvoices(userID, 5) recentInvoices, err := h.db.GetRecentInvoices(userID, 5)
if err != nil { if err != nil {
recentInvoices = []database.Invoice{} recentInvoices = []models.Invoice{}
} }
return Render(c, HomePage(userEmail, recentProducts, recentInvoices)) return view.Render(c, view.HomePage(userEmail, recentProducts, recentInvoices))
} }

View File

@@ -1,5 +1,6 @@
package web package handler
import "billit/internal/view"
import ( import (
"billit/internal/database" "billit/internal/database"
"net/http" "net/http"
@@ -26,7 +27,7 @@ func (h *InvoicesHandlers) InvoicesListHandler(c echo.Context) error {
invoices, err := h.db.GetAllInvoices(userID) invoices, err := h.db.GetAllInvoices(userID)
if err != nil { if err != nil {
return RenderServerError(c, "Failed to load invoices. Please try again.") return view.RenderServerError(c, "Failed to load invoices. Please try again.")
} }
return Render(c, InvoicesPage(invoices)) 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))
}

View File

@@ -1,5 +1,7 @@
package web package handler
import "billit/internal/models"
import "billit/internal/view"
import ( import (
"billit/internal/database" "billit/internal/database"
"net/http" "net/http"
@@ -31,14 +33,14 @@ func (h *ProductHandlers) ProductListHandler(c echo.Context) error {
userID := getUserID(c) userID := getUserID(c)
products, err := h.db.GetAllProducts(userID) products, err := h.db.GetAllProducts(userID)
if err != nil { if err != nil {
return RenderServerError(c, "Failed to load products. Please try again.") return view.RenderServerError(c, "Failed to load products. Please try again.")
} }
return Render(c, ProductListPage(products)) return view.Render(c, view.ProductListPage(products))
} }
// ProductCreatePageHandler renders the /product/create form page // ProductCreatePageHandler renders the /product/create form page
func (h *ProductHandlers) ProductCreatePageHandler(c echo.Context) error { func (h *ProductHandlers) ProductCreatePageHandler(c echo.Context) error {
return Render(c, ProductCreatePage()) return view.Render(c, view.ProductCreatePage())
} }
// ProductEditPageHandler renders the /product/edit/:sku form page // ProductEditPageHandler renders the /product/edit/:sku form page
@@ -48,9 +50,9 @@ func (h *ProductHandlers) ProductEditPageHandler(c echo.Context) error {
product, err := h.db.GetProductBySKU(sku, userID) product, err := h.db.GetProductBySKU(sku, userID)
if err != nil || product == nil { if err != nil || product == nil {
return RenderNotFound(c, "Product not found or you don't have access to it.") return view.RenderNotFound(c, "Product not found or you don't have access to it.")
} }
return Render(c, ProductEditPage(*product)) return view.Render(c, view.ProductEditPage(*product))
} }
// ProductCreateHandler handles POST /product/create // ProductCreateHandler handles POST /product/create
@@ -115,7 +117,7 @@ func (h *ProductHandlers) ProductCreateHandler(c echo.Context) error {
unit = "pcs" unit = "pcs"
} }
product := database.Product{ product := models.Product{
SKU: sku, SKU: sku,
Name: name, Name: name,
HSNCode: hsn, HSNCode: hsn,
@@ -194,7 +196,7 @@ func (h *ProductHandlers) ProductUpdateHandler(c echo.Context) error {
unit = "pcs" unit = "pcs"
} }
product := database.Product{ product := models.Product{
SKU: sku, SKU: sku,
Name: name, Name: name,
HSNCode: hsn, HSNCode: hsn,

View File

@@ -1,10 +1,10 @@
package auth package logic
import ( import (
"billit/internal/models"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"errors" "errors"
"net/http"
"os" "os"
"time" "time"
@@ -19,24 +19,12 @@ var (
ErrUserExists = errors.New("user already exists") ErrUserExists = errors.New("user already exists")
) )
// Config holds auth configuration // AuthConfig holds auth configuration
type Config struct { type AuthConfig struct {
JWTSecret []byte JWTSecret []byte
CookieDomain string
CookieSecure bool
TokenDuration time.Duration TokenDuration time.Duration
} }
// User represents an authenticated user
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Password string `json:"-"` // Never expose password hash
CompanyDetails string `json:"company_details"`
BankDetails string `json:"bank_details"`
CreatedAt string `json:"created_at"`
}
// Claims represents JWT claims // Claims represents JWT claims
type Claims struct { type Claims struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
@@ -44,21 +32,21 @@ type Claims struct {
jwt.RegisteredClaims jwt.RegisteredClaims
} }
// Service handles authentication // AuthService handles authentication logic
type Service struct { type AuthService struct {
config Config config AuthConfig
users UserStore users UserStore
} }
// UserStore interface for user persistence // UserStore interface for user persistence (subset of database.Service)
type UserStore interface { type UserStore interface {
CreateUser(email, passwordHash string) (*User, error) CreateUser(email, passwordHash string) (*models.User, error)
GetUserByEmail(email string) (*User, error) GetUserByEmail(email string) (*models.User, error)
GetUserByID(id string) (*User, error) GetUserByID(id string) (*models.User, error)
} }
// NewService creates a new auth service // NewAuthService creates a new auth service
func NewService(users UserStore) *Service { func NewAuthService(users UserStore) *AuthService {
secret := os.Getenv("JWT_SECRET") secret := os.Getenv("JWT_SECRET")
if secret == "" { if secret == "" {
// Generate a random secret if not provided (not recommended for production) // Generate a random secret if not provided (not recommended for production)
@@ -67,14 +55,9 @@ func NewService(users UserStore) *Service {
secret = hex.EncodeToString(b) secret = hex.EncodeToString(b)
} }
domain := os.Getenv("COOKIE_DOMAIN") return &AuthService{
secure := os.Getenv("COOKIE_SECURE") == "true" config: AuthConfig{
return &Service{
config: Config{
JWTSecret: []byte(secret), JWTSecret: []byte(secret),
CookieDomain: domain,
CookieSecure: secure,
TokenDuration: 24 * time.Hour, TokenDuration: 24 * time.Hour,
}, },
users: users, users: users,
@@ -98,7 +81,7 @@ func CheckPassword(password, hash string) bool {
} }
// Register creates a new user account // Register creates a new user account
func (s *Service) Register(email, password string) (*User, error) { func (s *AuthService) Register(email, password string) (*models.User, error) {
// Check if user exists // Check if user exists
existing, _ := s.users.GetUserByEmail(email) existing, _ := s.users.GetUserByEmail(email)
if existing != nil { if existing != nil {
@@ -120,7 +103,7 @@ func (s *Service) Register(email, password string) (*User, error) {
} }
// Login authenticates a user and returns a JWT token // Login authenticates a user and returns a JWT token
func (s *Service) Login(email, password string) (string, error) { func (s *AuthService) Login(email, password string) (string, error) {
user, err := s.users.GetUserByEmail(email) user, err := s.users.GetUserByEmail(email)
if err != nil || user == nil { if err != nil || user == nil {
return "", ErrInvalidCredentials return "", ErrInvalidCredentials
@@ -134,7 +117,7 @@ func (s *Service) Login(email, password string) (string, error) {
} }
// generateToken creates a new JWT token for a user // generateToken creates a new JWT token for a user
func (s *Service) generateToken(user *User) (string, error) { func (s *AuthService) generateToken(user *models.User) (string, error) {
now := time.Now() now := time.Now()
claims := &Claims{ claims := &Claims{
UserID: user.ID, UserID: user.ID,
@@ -153,7 +136,7 @@ func (s *Service) generateToken(user *User) (string, error) {
} }
// ValidateToken validates a JWT token and returns the claims // ValidateToken validates a JWT token and returns the claims
func (s *Service) ValidateToken(tokenString string) (*Claims, error) { func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken return nil, ErrInvalidToken
@@ -172,36 +155,8 @@ func (s *Service) ValidateToken(tokenString string) (*Claims, error) {
return nil, ErrInvalidToken return nil, ErrInvalidToken
} }
// CreateAuthCookie creates an HTTP-only secure cookie for the token
func (s *Service) CreateAuthCookie(token string) *http.Cookie {
return &http.Cookie{
Name: "auth_token",
Value: token,
Path: "/",
Domain: s.config.CookieDomain,
MaxAge: int(s.config.TokenDuration.Seconds()),
HttpOnly: true, // Prevents JavaScript access
Secure: s.config.CookieSecure, // Only send over HTTPS in production
SameSite: http.SameSiteStrictMode,
}
}
// ClearAuthCookie returns a cookie that clears the auth token
func (s *Service) ClearAuthCookie() *http.Cookie {
return &http.Cookie{
Name: "auth_token",
Value: "",
Path: "/",
Domain: s.config.CookieDomain,
MaxAge: -1,
HttpOnly: true,
Secure: s.config.CookieSecure,
SameSite: http.SameSiteStrictMode,
}
}
// GetUserFromToken retrieves the user from a valid token // GetUserFromToken retrieves the user from a valid token
func (s *Service) GetUserFromToken(tokenString string) (*User, error) { func (s *AuthService) GetUserFromToken(tokenString string) (*models.User, error) {
claims, err := s.ValidateToken(tokenString) claims, err := s.ValidateToken(tokenString)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -1,4 +1,4 @@
package gst package logic
import ( import (
"math" "math"

46
internal/models/models.go Normal file
View File

@@ -0,0 +1,46 @@
package models
// Product represents a product in the database
type Product struct {
SKU string `json:"sku"`
Name string `json:"name"`
HSNCode string `json:"hsn_code"`
BasePrice float64 `json:"base_price"`
WholesalePrice float64 `json:"wholesale_price"`
GSTRate float64 `json:"gst_rate"`
SmallOrderQty int `json:"small_order_qty"`
SmallOrderFee float64 `json:"small_order_fee"` // Convenience fee for orders below SmallOrderQty
Unit string `json:"unit"` // Unit of measurement (e.g., "pcs", "kg", "box")
UserID string `json:"user_id"`
CreatedAt string `json:"created_at"`
}
// Invoice represents a stored invoice
type Invoice struct {
ID string `json:"id"` // UUID
HumanReadableID string `json:"human_readable_id"` // Formatted ID like INV/12-2025/001
Data string `json:"data"` // JSON blob of invoice details
UserID string `json:"user_id"`
CreatedAt string `json:"created_at"`
}
// User represents an authenticated user
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Password string `json:"-"`
CompanyDetails string `json:"company_details"` // Multiline company details for invoice header
BankDetails string `json:"bank_details"` // Multiline bank details for invoice footer
InvoicePrefix string `json:"invoice_prefix"` // Prefix for invoice IDs (e.g., INV, BILL)
InvoiceCounter int `json:"invoice_counter"` // Auto-incrementing counter for invoice serial numbers
CreatedAt string `json:"created_at"`
}
// BuyerDetails represents a buyer/customer for invoices
type BuyerDetails struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"` // Display name for selection
Details string `json:"details"` // Multiline buyer details
CreatedAt string `json:"created_at"`
}

View File

@@ -1,12 +1,11 @@
package server package server
import ( import (
"billit/internal/handler"
"billit/internal/logic"
"billit/internal/view"
"net/http" "net/http"
"billit/internal/api"
"billit/internal/auth"
"billit/internal/web"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
) )
@@ -25,23 +24,24 @@ func (s *Server) RegisterRoutes() http.Handler {
})) }))
// Static files // Static files
fileServer := http.FileServer(http.FS(web.Files)) if s.assetsFS != nil {
e.GET("/assets/*", echo.WrapHandler(fileServer)) fileServer := http.FileServer(http.FS(s.assetsFS))
e.GET("/assets/*", echo.WrapHandler(fileServer))
}
// ======================================== // ========================================
// Auth Setup // Auth Setup
// ======================================== // ========================================
userStore := auth.NewDBUserStore(s.db) authService := logic.NewAuthService(s.db)
authService := auth.NewService(userStore) authHandlers := handler.NewAuthHandlers(authService)
authHandlers := web.NewAuthHandlers(authService)
// ======================================== // ========================================
// API Routes (JSON responses) - Health only, products/invoice via web UI // API Routes (JSON responses) - Health only
// ======================================== // ========================================
apiHandlers := api.NewHandlers(s.db) healthHandlers := handler.NewHealthHandlers(s.db)
apiGroup := e.Group("/api") apiGroup := e.Group("/api")
{ {
apiGroup.GET("/health", apiHandlers.HealthHandler) apiGroup.GET("/health", healthHandlers.HealthHandler)
} }
// ======================================== // ========================================
@@ -60,17 +60,17 @@ func (s *Server) RegisterRoutes() http.Handler {
protected.Use(authHandlers.AuthMiddleware) protected.Use(authHandlers.AuthMiddleware)
// Home // Home
homeHandlers := web.NewHomeHandlers(s.db) homeHandlers := handler.NewHomeHandlers(s.db)
protected.GET("/home", homeHandlers.HomePageHandler) protected.GET("/home", homeHandlers.HomePageHandler)
// Account routes // Account routes
accountHandlers := web.NewAccountHandlers(s.db, authService) accountHandlers := handler.NewAccountHandlers(s.db, authService)
protected.GET("/account", accountHandlers.AccountPageHandler) protected.GET("/account", accountHandlers.AccountPageHandler)
protected.POST("/account/details", accountHandlers.UpdateDetailsHandler) protected.POST("/account/details", accountHandlers.UpdateDetailsHandler)
protected.POST("/account/password", accountHandlers.ChangePasswordHandler) protected.POST("/account/password", accountHandlers.ChangePasswordHandler)
// Buyer routes // Buyer routes
buyerHandlers := web.NewBuyerHandlers(s.db) buyerHandlers := handler.NewBuyerHandlers(s.db)
protected.GET("/buyer", buyerHandlers.BuyerListHandler) protected.GET("/buyer", buyerHandlers.BuyerListHandler)
protected.GET("/buyer/create", buyerHandlers.BuyerCreatePageHandler) protected.GET("/buyer/create", buyerHandlers.BuyerCreatePageHandler)
protected.POST("/buyer/create", buyerHandlers.BuyerCreateHandler) protected.POST("/buyer/create", buyerHandlers.BuyerCreateHandler)
@@ -79,11 +79,15 @@ func (s *Server) RegisterRoutes() http.Handler {
protected.DELETE("/buyer/:id", buyerHandlers.BuyerDeleteHandler) protected.DELETE("/buyer/:id", buyerHandlers.BuyerDeleteHandler)
// Invoices list // Invoices list
invoicesHandlers := web.NewInvoicesHandlers(s.db) invoicesHandlers := handler.NewInvoicesHandlers(s.db)
protected.GET("/invoice", invoicesHandlers.InvoicesListHandler) protected.GET("/invoice", invoicesHandlers.InvoicesListHandler)
// Modal routes
modalHandlers := handler.NewModalHandlers()
protected.GET("/modal/confirm", modalHandlers.ConfirmHandler)
// Product routes (web UI) // Product routes (web UI)
productHandlers := web.NewProductHandlers(s.db) productHandlers := handler.NewProductHandlers(s.db)
protected.GET("/product", productHandlers.ProductListHandler) protected.GET("/product", productHandlers.ProductListHandler)
protected.GET("/product/create", productHandlers.ProductCreatePageHandler) protected.GET("/product/create", productHandlers.ProductCreatePageHandler)
protected.POST("/product/create", productHandlers.ProductCreateHandler) protected.POST("/product/create", productHandlers.ProductCreateHandler)
@@ -92,7 +96,7 @@ func (s *Server) RegisterRoutes() http.Handler {
protected.DELETE("/product/:sku", productHandlers.ProductDeleteHandler) protected.DELETE("/product/:sku", productHandlers.ProductDeleteHandler)
// Billing routes (web UI) // Billing routes (web UI)
billingHandlers := web.NewBillingHandlers(s.db) billingHandlers := handler.NewBillingHandlers(s.db)
protected.GET("/billing", billingHandlers.BillingPageHandler) protected.GET("/billing", billingHandlers.BillingPageHandler)
protected.POST("/billing/calculate", billingHandlers.CalculateBillHandler) protected.POST("/billing/calculate", billingHandlers.CalculateBillHandler)
protected.POST("/billing/generate", billingHandlers.GenerateBillHandler) protected.POST("/billing/generate", billingHandlers.GenerateBillHandler)
@@ -102,17 +106,17 @@ func (s *Server) RegisterRoutes() http.Handler {
protected.GET("/invoice/:id", billingHandlers.ShowInvoiceHandler) protected.GET("/invoice/:id", billingHandlers.ShowInvoiceHandler)
// Legacy health check (kept for backward compatibility) // Legacy health check (kept for backward compatibility)
e.GET("/health", apiHandlers.HealthHandler) e.GET("/health", healthHandlers.HealthHandler)
// Custom 404 handler for Echo HTTP errors // Custom 404 handler for Echo HTTP errors
e.HTTPErrorHandler = func(err error, c echo.Context) { e.HTTPErrorHandler = func(err error, c echo.Context) {
if he, ok := err.(*echo.HTTPError); ok { if he, ok := err.(*echo.HTTPError); ok {
switch he.Code { switch he.Code {
case http.StatusNotFound: case http.StatusNotFound:
_ = web.RenderNotFound(c, "") _ = view.RenderNotFound(c, "")
return return
case http.StatusInternalServerError: case http.StatusInternalServerError:
_ = web.RenderServerError(c, "") _ = view.RenderServerError(c, "")
return return
} }
} }
@@ -122,8 +126,8 @@ func (s *Server) RegisterRoutes() http.Handler {
// Catch-all for undefined routes (must be last) // Catch-all for undefined routes (must be last)
e.RouteNotFound("/*", func(c echo.Context) error { e.RouteNotFound("/*", func(c echo.Context) error {
return web.RenderNotFound(c, "") return view.RenderNotFound(c, "")
}) })
return e return e
} }

View File

@@ -1,34 +0,0 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
"billit/internal/database"
"github.com/labstack/echo/v4"
)
func TestHomeRoute(t *testing.T) {
// Create a minimal server with db for testing
db := database.New()
s := &Server{db: db}
handler := s.RegisterRoutes()
req := httptest.NewRequest(http.MethodGet, "/", nil)
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Errorf("home route wrong status code = %v, want %v", resp.Code, http.StatusOK)
}
}
func TestRouterSetup(t *testing.T) {
// Test that Echo router can be set up without panic
e := echo.New()
if e == nil {
t.Error("failed to create echo instance")
}
}

View File

@@ -10,20 +10,22 @@ import (
_ "github.com/joho/godotenv/autoload" _ "github.com/joho/godotenv/autoload"
"billit/internal/database" "billit/internal/database"
"io/fs"
) )
type Server struct { type Server struct {
port int port int
db database.Service db database.Service
assetsFS fs.FS
} }
func NewServer() *http.Server { func NewServer(assetsFS fs.FS) *http.Server {
port, _ := strconv.Atoi(os.Getenv("PORT")) port, _ := strconv.Atoi(os.Getenv("PORT"))
NewServer := &Server{ NewServer := &Server{
port: port, port: port,
db: database.New(),
db: database.New(), assetsFS: assetsFS,
} }
// Declare Server config // Declare Server config

View File

@@ -1,4 +1,4 @@
package web package view
import ( import (
"github.com/a-h/templ" "github.com/a-h/templ"

View File

@@ -1,107 +0,0 @@
package web
import (
"billit/internal/auth"
"billit/internal/database"
"net/http"
"github.com/labstack/echo/v4"
)
// AccountHandlers holds references for account operations
type AccountHandlers struct {
db database.Service
auth *auth.Service
}
// NewAccountHandlers creates handlers with db and auth access
func NewAccountHandlers(db database.Service, authService *auth.Service) *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 RenderServerError(c, "Failed to load account details.")
}
return Render(c, 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 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 Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Failed to update details"))
}
return Render(c, 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 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 !auth.CheckPassword(currentPassword, user.Password) {
return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Current password is incorrect"))
}
// Validate new password
if len(newPassword) < 8 {
return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "New password must be at least 8 characters"))
}
if newPassword != confirmPassword {
return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "New passwords do not match"))
}
// Hash new password
hash, err := auth.HashPassword(newPassword)
if err != nil {
return Render(c, 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 Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Failed to update password"))
}
return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "Password changed successfully", ""))
}

View File

@@ -1,249 +0,0 @@
// Dialog component for Billit
// Replaces browser confirm/alert dialogs with custom styled modals
(function() {
'use strict';
// Dialog state
let currentResolve = null;
let currentElement = null;
// Create dialog HTML structure
function createDialogElement() {
const dialog = document.createElement('div');
dialog.id = 'dialog';
dialog.className = 'dialog-overlay';
dialog.innerHTML = `
<div class="dialog-box">
<div class="dialog-header">
<h3 class="dialog-title"></h3>
</div>
<div class="dialog-body">
<p class="dialog-message"></p>
</div>
<div class="dialog-footer">
<button type="button" class="btn btn-outline dialog-cancel">Cancel</button>
<button type="button" class="btn btn-danger dialog-confirm">Confirm</button>
</div>
</div>
`;
document.body.appendChild(dialog);
// Event listeners
dialog.querySelector('.dialog-cancel').addEventListener('click', () => closeDialog(false));
dialog.querySelector('.dialog-confirm').addEventListener('click', () => closeDialog(true));
dialog.addEventListener('click', (e) => {
if (e.target === dialog) closeDialog(false);
});
// Escape key closes dialog
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && dialog.classList.contains('dialog-open')) {
closeDialog(false);
}
});
return dialog;
}
// Get or create dialog element
function getDialog() {
return document.getElementById('dialog') || createDialogElement();
}
// Open dialog with options
function openDialog(options) {
const dialog = getDialog();
const title = options.title || 'Confirm';
const message = options.message || 'Are you sure?';
const confirmText = options.confirmText || 'Confirm';
const cancelText = options.cancelText || 'Cancel';
const confirmClass = options.confirmClass || 'btn-danger';
const html = options.html || null;
const wide = options.wide || false;
const allowClose = options.allowClose !== false;
dialog.querySelector('.dialog-title').textContent = title;
// Support HTML content
if (html) {
dialog.querySelector('.dialog-body').innerHTML = html;
} else {
dialog.querySelector('.dialog-body').innerHTML = '<p class="dialog-message">' + escapeHtml(message) + '</p>';
}
dialog.querySelector('.dialog-confirm').textContent = confirmText;
dialog.querySelector('.dialog-confirm').className = 'btn ' + confirmClass + ' dialog-confirm';
dialog.querySelector('.dialog-cancel').textContent = cancelText;
// Show/hide cancel button for alert-style dialogs
dialog.querySelector('.dialog-cancel').style.display = options.showCancel !== false ? '' : 'none';
// Wide mode for larger content
dialog.querySelector('.dialog-box').style.maxWidth = wide ? '600px' : '400px';
// Store allowClose setting
dialog.dataset.allowClose = allowClose;
dialog.classList.add('dialog-open');
dialog.querySelector('.dialog-confirm').focus();
return new Promise((resolve) => {
currentResolve = resolve;
});
}
// Close dialog
function closeDialog(result) {
const dialog = getDialog();
// Check if closing is allowed (for disclaimer)
if (!result && dialog.dataset.allowClose === 'false') {
return;
}
dialog.classList.remove('dialog-open');
if (currentResolve) {
currentResolve(result);
currentResolve = null;
}
// If there's a pending HTMX request, trigger it
if (result && currentElement) {
htmx.trigger(currentElement, 'confirmed');
}
currentElement = null;
}
// Escape HTML for safe rendering
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Public API
window.Dialog = {
confirm: function(options) {
if (typeof options === 'string') {
options = { message: options };
}
return openDialog({ ...options, showCancel: true });
},
alert: function(options) {
if (typeof options === 'string') {
options = { message: options };
}
return openDialog({
...options,
showCancel: false,
confirmText: options.confirmText || 'OK',
confirmClass: options.confirmClass || 'btn-primary'
});
},
// Custom dialog with HTML content
custom: function(options) {
return openDialog(options);
}
};
// HTMX integration: intercept hx-confirm and use custom dialog
// Elements can customize the dialog with data attributes:
// data-dialog-title="Custom Title"
// data-dialog-confirm="Button Text"
// data-dialog-class="btn-danger" (or btn-primary, etc.)
// If no data-dialog-* attributes are present, uses browser default confirm
document.addEventListener('htmx:confirm', function(e) {
const element = e.detail.elt;
// Check if element wants custom dialog (has any data-dialog-* attribute)
const hasCustomDialog = element.dataset.dialogTitle ||
element.dataset.dialogConfirm ||
element.dataset.dialogClass;
if (!hasCustomDialog) {
return; // Let default browser confirm handle it
}
// Prevent default browser confirm
e.preventDefault();
const message = e.detail.question;
const title = element.dataset.dialogTitle || 'Confirm';
const confirmText = element.dataset.dialogConfirm || 'Confirm';
const confirmClass = element.dataset.dialogClass || 'btn-primary';
// Store element for later
currentElement = element;
Dialog.confirm({
title: title,
message: message,
confirmText: confirmText,
confirmClass: confirmClass
}).then(function(confirmed) {
if (confirmed) {
// Issue the request
e.detail.issueRequest(true);
}
currentElement = null;
});
});
// Disclaimer dialog - show on first visit
function showDisclaimer() {
const DISCLAIMER_KEY = 'billit_disclaimer_accepted';
// Check if already accepted
if (localStorage.getItem(DISCLAIMER_KEY)) {
return;
}
const disclaimerHTML = `
<div class="disclaimer-content">
<p style="font-weight: bold; margin-bottom: 15px;">
Please read these terms carefully before using this software. By proceeding, you agree to the conditions below:
</p>
<ul style="padding-left: 20px; line-height: 1.8; margin: 0;">
<li>
<strong>1. FREE OF CHARGE & CPA EXEMPTION:</strong> This software is provided strictly <strong>"Free of Charge"</strong> and without any monetary consideration. It therefore does not constitute a "Service" under the Indian Consumer Protection Act, 2019.
</li>
<li style="margin-top: 10px;">
<strong>2. "AS IS" & NO WARRANTY:</strong> The software is provided <strong>"AS IS"</strong>. The developer provides <strong>NO WARRANTY</strong>, express or implied, regarding its performance, accuracy, security, or suitability for any purpose.
</li>
<li style="margin-top: 10px;">
<strong>3. USER ASSUMPTION OF RISK:</strong> The developer is not liable for any financial losses, data corruption, calculation errors, or legal issues resulting from the use or misuse of this application. Users assume all associated risks and agree to indemnify and hold harmless the developer.
</li>
</ul>
<p style="font-size: 0.9em; font-style: italic; color: #666; margin-top: 15px; margin-bottom: 0;">
<small>Consult a qualified legal or financial advisor before relying on any data generated by this tool.</small>
</p>
</div>
`;
Dialog.custom({
title: '⚠️ GENERAL USE & NO LIABILITY DISCLAIMER',
html: disclaimerHTML,
confirmText: 'I Understand & Accept',
confirmClass: 'btn-primary',
showCancel: false,
wide: true,
allowClose: false
}).then(function(accepted) {
if (accepted) {
localStorage.setItem(DISCLAIMER_KEY, Date.now().toString());
}
});
}
// Show disclaimer when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', showDisclaimer);
} else {
showDisclaimer();
}
})();