quite a lot of things

This commit is contained in:
2025-12-06 03:05:44 +05:30
parent 39c61b7790
commit 28733e22d3
42 changed files with 4214 additions and 204 deletions

15
.env.example Normal file
View File

@@ -0,0 +1,15 @@
# Server Configuration
PORT=3000
# Database
DB_PATH=./db/dev.db
# Authentication
# Generate a secure random secret: openssl rand -hex 32
JWT_SECRET=change_me_to_a_secure_random_string
# Cookie Settings
# Set your domain for production (e.g., .example.com)
COOKIE_DOMAIN=
# Set to true when using HTTPS
COOKIE_SECURE=false

3
.gitignore vendored
View File

@@ -36,4 +36,7 @@ main
# Tailwind CSS # Tailwind CSS
cmd/web/assets/css/output.css cmd/web/assets/css/output.css
tailwindcss tailwindcss
node_modules/
# Docker image tarball
image.tar

View File

@@ -1,23 +1,25 @@
FROM golang:1.24.4-alpine AS build FROM golang:1.25.1-alpine AS build
RUN apk add --no-cache curl libstdc++ libgcc alpine-sdk RUN apk add --no-cache curl libstdc++ libgcc alpine-sdk npm
WORKDIR /app WORKDIR /app
# Install sass for SCSS compilation
RUN npm install -g sass
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN go install github.com/a-h/templ/cmd/templ@latest && \
templ generate && \ # Compile SCSS to CSS
curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64-musl -o tailwindcss && \ RUN sass internal/web/assets/scss/main.scss internal/web/assets/css/output.css --style=compressed
chmod +x tailwindcss && \
./tailwindcss -i cmd/web/styles/input.css -o cmd/web/assets/css/output.css
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
FROM alpine:3.20.1 AS prod FROM alpine:3.20.1 AS prod
WORKDIR /app WORKDIR /app
COPY --from=build /app/main /app/main COPY --from=build /app/main /app/main
COPY --from=build /app/internal/web/assets /app/internal/web/assets
EXPOSE ${PORT} EXPOSE ${PORT}
CMD ["./main"] CMD ["./main"]

122
Makefile
View File

@@ -1,78 +1,66 @@
# Simple Makefile for a Go project # ============================================
# BILLIT - Makefile
# ============================================
.PHONY: all build run dev clean scss scss-watch test docker-build docker-run
# Variables
APP_NAME := billit
MAIN_PATH := ./cmd/api
SCSS_DIR := internal/web/assets/scss
CSS_DIR := internal/web/assets/css
# Default target
all: scss build
# Build the application # Build the application
all: build test build:
templ-install: @echo "Building $(APP_NAME)..."
@if ! command -v templ > /dev/null; then \ go build -o bin/$(APP_NAME) $(MAIN_PATH)
read -p "Go's 'templ' is not installed on your machine. Do you want to install it? [Y/n] " choice; \
if [ "$$choice" != "n" ] && [ "$$choice" != "N" ]; then \
go install github.com/a-h/templ/cmd/templ@latest; \
if [ ! -x "$$(command -v templ)" ]; then \
echo "templ installation failed. Exiting..."; \
exit 1; \
fi; \
else \
echo "You chose not to install templ. Exiting..."; \
exit 1; \
fi; \
fi
tailwind-install:
@if [ ! -f tailwindcss ]; then curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-macos-x64 -o tailwindcss; fi
@chmod +x tailwindcss
build: tailwind-install templ-install
@echo "Building..."
@templ generate
@./tailwindcss -i cmd/web/styles/input.css -o cmd/web/assets/css/output.css
@CGO_ENABLED=1 GOOS=linux go build -o main cmd/api/main.go
# Run the application # Run the application
run: run: scss
@go run cmd/api/main.go @echo "Running $(APP_NAME)..."
# Create DB container go run $(MAIN_PATH)
docker-run:
@if docker compose up --build 2>/dev/null; then \
: ; \
else \
echo "Falling back to Docker Compose V1"; \
docker-compose up --build; \
fi
# Shutdown DB container # Development mode with hot reload
docker-down: dev:
@if docker compose down 2>/dev/null; then \ @echo "Starting development server..."
: ; \ air
else \
echo "Falling back to Docker Compose V1"; \
docker-compose down; \
fi
# Test the application # Compile SCSS to CSS
test: scss:
@echo "Testing..." @echo "Compiling SCSS..."
@go test ./... -v sass $(SCSS_DIR)/main.scss $(CSS_DIR)/output.css --style=compressed
# Clean the binary # Watch SCSS for changes
scss-watch:
@echo "Watching SCSS for changes..."
sass $(SCSS_DIR)/main.scss $(CSS_DIR)/output.css --style=compressed --watch
# Clean build artifacts
clean: clean:
@echo "Cleaning..." @echo "Cleaning..."
@rm -f main rm -rf bin/
rm -f $(CSS_DIR)/output.css
rm -f $(CSS_DIR)/output.css.map
# Live Reload # Run tests
watch: test:
@if command -v air > /dev/null; then \ @echo "Running tests..."
air; \ go test ./...
echo "Watching...";\
else \
read -p "Go's 'air' is not installed on your machine. Do you want to install it? [Y/n] " choice; \
if [ "$$choice" != "n" ] && [ "$$choice" != "N" ]; then \
go install github.com/air-verse/air@latest; \
air; \
echo "Watching...";\
else \
echo "You chose not to install air. Exiting..."; \
exit 1; \
fi; \
fi
.PHONY: all build run test clean watch tailwind-install templ-install # Docker build
docker-build: scss
@echo "Building Docker image..."
docker compose -f compose.build.yml build
# Docker run
docker-run:
@echo "Running Docker container..."
docker compose up
# Build for production
release: scss
@echo "Building for production..."
CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o bin/$(APP_NAME) $(MAIN_PATH)

View File

@@ -1,19 +0,0 @@
package web
templ Base() {
<!DOCTYPE html>
<html lang="en" class="h-screen">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Go Blueprint Hello</title>
<link href="assets/css/output.css" rel="stylesheet"/>
<script src="assets/js/htmx.min.js"></script>
</head>
<body class="bg-gray-100">
<main class="max-w-sm mx-auto p-4">
{ children... }
</main>
</body>
</html>
}

View File

@@ -1,21 +0,0 @@
package web
import (
"log"
"net/http"
)
func HelloWebHandler(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
}
name := r.FormValue("name")
component := HelloPost(name)
err = component.Render(r.Context(), w)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
log.Fatalf("Error rendering in HelloWebHandler: %e", err)
}
}

View File

@@ -1,17 +0,0 @@
package web
templ HelloForm() {
@Base() {
<form hx-post="/hello" method="POST" hx-target="#hello-container">
<input class="bg-gray-200 text-black p-2 border border-gray-400 rounded-lg"id="name" name="name" type="text"/>
<button type="submit" class="bg-orange-500 hover:bg-orange-700 text-white py-2 px-4 rounded">Submit</button>
</form>
<div id="hello-container"></div>
}
}
templ HelloPost(name string) {
<div class="bg-green-100 p-4 shadow-md rounded-lg mt-6">
<p>Hello, { name }</p>
</div>
}

View File

@@ -1 +0,0 @@
@import "tailwindcss"

11
compose.build.yml Normal file
View File

@@ -0,0 +1,11 @@
# Build-only compose file
# Usage: docker compose -f compose.build.yml build
services:
app:
build:
context: .
dockerfile: Dockerfile
target: prod
image: billit:latest
platform: linux/amd64

18
compose.deploy.yml Normal file
View File

@@ -0,0 +1,18 @@
# Deployment compose file
# Usage: docker compose -f compose.deploy.yml up -d
services:
app:
image: billit:latest
container_name: billit
restart: unless-stopped
ports:
- "3020:3000"
env_file:
- .env
volumes:
- db:/app/db
volumes:
db:
driver: local

BIN
db/dev.db Normal file

Binary file not shown.

View File

@@ -1,17 +0,0 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
target: prod
restart: unless-stopped
ports:
- ${PORT}:${PORT}
environment:
APP_ENV: ${APP_ENV}
PORT: ${PORT}
BLUEPRINT_DB_URL: ${BLUEPRINT_DB_URL}
volumes:
- sqlite_bp:/app/db
volumes:
sqlite_bp:

5
go.mod
View File

@@ -4,18 +4,21 @@ go 1.25.1
require ( require (
github.com/a-h/templ v0.3.960 github.com/a-h/templ v0.3.960
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/mattn/go-sqlite3 v1.14.32
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
) )
require ( require (
github.com/google/uuid v1.6.0
github.com/labstack/gommon v0.4.2 // indirect github.com/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.45.0 // indirect golang.org/x/crypto v0.45.0
golang.org/x/net v0.47.0 // indirect golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect golang.org/x/text v0.31.0 // indirect

6
go.sum
View File

@@ -2,8 +2,12 @@ github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=
github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
@@ -18,6 +22,8 @@ github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuE
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=

28
internal/api/handlers.go Normal file
View File

@@ -0,0 +1,28 @@
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.

211
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,211 @@
package auth
import (
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
var (
ErrInvalidCredentials = errors.New("invalid email or password")
ErrInvalidToken = errors.New("invalid or expired token")
ErrUserNotFound = errors.New("user not found")
ErrUserExists = errors.New("user already exists")
)
// Config holds auth configuration
type Config struct {
JWTSecret []byte
CookieDomain string
CookieSecure bool
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
type Claims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
jwt.RegisteredClaims
}
// Service handles authentication
type Service struct {
config Config
users UserStore
}
// UserStore interface for user persistence
type UserStore interface {
CreateUser(email, passwordHash string) (*User, error)
GetUserByEmail(email string) (*User, error)
GetUserByID(id string) (*User, error)
}
// NewService creates a new auth service
func NewService(users UserStore) *Service {
secret := os.Getenv("JWT_SECRET")
if secret == "" {
// Generate a random secret if not provided (not recommended for production)
b := make([]byte, 32)
rand.Read(b)
secret = hex.EncodeToString(b)
}
domain := os.Getenv("COOKIE_DOMAIN")
secure := os.Getenv("COOKIE_SECURE") == "true"
return &Service{
config: Config{
JWTSecret: []byte(secret),
CookieDomain: domain,
CookieSecure: secure,
TokenDuration: 24 * time.Hour,
},
users: users,
}
}
// HashPassword hashes a password using bcrypt with high cost
func HashPassword(password string) (string, error) {
// Use cost of 12 for good security/performance balance
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return "", err
}
return string(hash), nil
}
// CheckPassword verifies a password against a hash
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
// Register creates a new user account
func (s *Service) Register(email, password string) (*User, error) {
// Check if user exists
existing, _ := s.users.GetUserByEmail(email)
if existing != nil {
return nil, ErrUserExists
}
// Validate password strength
if len(password) < 8 {
return nil, errors.New("password must be at least 8 characters")
}
// Hash password
hash, err := HashPassword(password)
if err != nil {
return nil, err
}
return s.users.CreateUser(email, hash)
}
// Login authenticates a user and returns a JWT token
func (s *Service) Login(email, password string) (string, error) {
user, err := s.users.GetUserByEmail(email)
if err != nil || user == nil {
return "", ErrInvalidCredentials
}
if !CheckPassword(password, user.Password) {
return "", ErrInvalidCredentials
}
return s.generateToken(user)
}
// generateToken creates a new JWT token for a user
func (s *Service) generateToken(user *User) (string, error) {
now := time.Now()
claims := &Claims{
UserID: user.ID,
Email: user.Email,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(s.config.TokenDuration)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
Issuer: "billit",
Subject: user.ID,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.config.JWTSecret)
}
// ValidateToken validates a JWT token and returns the claims
func (s *Service) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return s.config.JWTSecret, nil
})
if err != nil {
return nil, ErrInvalidToken
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
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
func (s *Service) GetUserFromToken(tokenString string) (*User, error) {
claims, err := s.ValidateToken(tokenString)
if err != nil {
return nil, err
}
return s.users.GetUserByID(claims.UserID)
}

69
internal/auth/store.go Normal file
View File

@@ -0,0 +1,69 @@
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

@@ -3,9 +3,11 @@ package database
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"log" "log"
"os" "os"
"path/filepath"
"strconv" "strconv"
"time" "time"
@@ -13,15 +15,88 @@ import (
_ "github.com/mattn/go-sqlite3" _ "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. // Service represents a service that interacts with a database.
type Service interface { type Service interface {
// Health returns a map of health status information. // Health returns a map of health status information.
// The keys and values in the map are service-specific.
Health() map[string]string Health() map[string]string
// Close terminates the database connection. // Close terminates the database connection.
// It returns an error if the connection cannot be closed.
Close() error 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 { type service struct {
@@ -29,7 +104,7 @@ type service struct {
} }
var ( var (
dburl = os.Getenv("BLUEPRINT_DB_URL") dburl = os.Getenv("DB_PATH")
dbInstance *service dbInstance *service
) )
@@ -39,19 +114,400 @@ func New() Service {
return dbInstance 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) db, err := sql.Open("sqlite3", dburl)
if err != nil { if err != nil {
// This will not be a connection error, but a DSN parse error or
// another initialization error.
log.Fatal(err) log.Fatal(err)
} }
dbInstance = &service{ dbInstance = &service{
db: db, db: db,
} }
// Initialize tables
dbInstance.initTables()
return dbInstance 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. // Health checks the health of the database connection by pinging the database.
// It returns a map with keys indicating various health statistics. // It returns a map with keys indicating various health statistics.
func (s *service) Health() map[string]string { func (s *service) Health() map[string]string {

158
internal/gst/calculator.go Normal file
View File

@@ -0,0 +1,158 @@
package gst
import (
"math"
)
// Rate represents standard GST rates
type Rate float64
const (
Rate0 Rate = 0.0
Rate5 Rate = 0.05
Rate12 Rate = 0.12
Rate18 Rate = 0.18
Rate28 Rate = 0.28
)
// CustomerType distinguishes between B2B (Wholesale) and B2C (Retail)
type CustomerType string
const (
CustomerWholesale CustomerType = "wholesale"
CustomerRetail CustomerType = "retail"
)
// Product represents a catalog item
type Product struct {
SKU string
Name string
HSNCode string
BasePrice float64 // Price before tax
WholesalePrice float64 // Discounted price for B2B
GSTRate Rate
SmallOrderQty int // Minimum quantity threshold
SmallOrderFee float64 // Convenience fee when quantity is below threshold
Unit string // Unit of measurement (e.g., "pcs", "kg", "box")
}
// LineItem represents a single row in the invoice
type LineItem struct {
Product Product
Quantity int
UnitPrice float64 // Actual price applied (wholesale vs retail)
TaxableVal float64 // Quantity * UnitPrice
CGSTAmount float64
SGSTAmount float64
IGSTAmount float64
TotalAmount float64
}
// Invoice represents the full bill
type Invoice struct {
LineItems []LineItem
SubTotal float64
TotalCGST float64
TotalSGST float64
TotalIGST float64
ConvenienceFee float64 // Flat fee for small orders (before tax)
ConvenienceFeeTax float64 // GST on convenience fee (18% fixed)
GrandTotal float64
CustomerType CustomerType
IsInterState bool // True if selling to a different state (IGST applies)
CompanyDetails string // Multiline company details (displayed above invoice table)
BuyerDetails string // Multiline buyer details (displayed above bank details)
BuyerName string // Buyer's name
BankDetails string // Multiline bank details (displayed at bottom of invoice)
}
// Calculator handles the GST logic
type Calculator struct{}
// NewCalculator creates a new calculator instance
func NewCalculator() *Calculator {
return &Calculator{}
}
// CalculateLineItem computes taxes for a single line
func (c *Calculator) CalculateLineItem(p Product, qty int, custType CustomerType, isInterState bool) LineItem {
// Determine price based on customer type
price := p.BasePrice
if custType == CustomerWholesale {
price = p.WholesalePrice
}
taxableVal := price * float64(qty)
rate := float64(p.GSTRate)
var cgst, sgst, igst float64
if isInterState {
igst = taxableVal * rate
} else {
// Intra-state: Split tax between Center and State
halfRate := rate / 2
cgst = taxableVal * halfRate
sgst = taxableVal * halfRate
}
total := taxableVal + cgst + sgst + igst
return LineItem{
Product: p,
Quantity: qty,
UnitPrice: price,
TaxableVal: round(taxableVal),
CGSTAmount: round(cgst),
SGSTAmount: round(sgst),
IGSTAmount: round(igst),
TotalAmount: round(total),
}
}
// CalculateInvoice computes totals for the entire invoice
func (c *Calculator) CalculateInvoice(items []LineItem, fee float64, isInterState bool) Invoice {
inv := Invoice{
LineItems: items,
ConvenienceFee: fee,
IsInterState: isInterState,
}
for _, item := range items {
inv.SubTotal += item.TaxableVal
inv.TotalCGST += item.CGSTAmount
inv.TotalSGST += item.SGSTAmount
inv.TotalIGST += item.IGSTAmount
}
// Convenience fee is taxable at 18% fixed rate
if fee > 0 {
feeTax := fee * 0.18 // 18% GST on convenience fee
inv.ConvenienceFeeTax = round(feeTax)
// Add convenience fee to taxable subtotal
inv.SubTotal += fee
// Add convenience fee tax to appropriate tax fields
if isInterState {
inv.TotalIGST += inv.ConvenienceFeeTax
} else {
// Split between CGST and SGST (9% each)
inv.TotalCGST += round(feeTax / 2)
inv.TotalSGST += round(feeTax / 2)
}
}
inv.GrandTotal = inv.SubTotal + inv.TotalCGST + inv.TotalSGST + inv.TotalIGST
// Rounding final totals
inv.SubTotal = round(inv.SubTotal)
inv.TotalCGST = round(inv.TotalCGST)
inv.TotalSGST = round(inv.TotalSGST)
inv.TotalIGST = round(inv.TotalIGST)
inv.GrandTotal = round(inv.GrandTotal)
return inv
}
func round(num float64) float64 {
return math.Round(num*100) / 100
}

View File

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

View File

@@ -3,8 +3,10 @@ package server
import ( import (
"net/http" "net/http"
"billit/cmd/web" "billit/internal/api"
"github.com/a-h/templ" "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"
) )
@@ -22,27 +24,106 @@ func (s *Server) RegisterRoutes() http.Handler {
MaxAge: 300, MaxAge: 300,
})) }))
// Static files
fileServer := http.FileServer(http.FS(web.Files)) fileServer := http.FileServer(http.FS(web.Files))
e.GET("/assets/*", echo.WrapHandler(fileServer)) e.GET("/assets/*", echo.WrapHandler(fileServer))
e.GET("/web", echo.WrapHandler(templ.Handler(web.HelloForm()))) // ========================================
e.POST("/hello", echo.WrapHandler(http.HandlerFunc(web.HelloWebHandler))) // Auth Setup
// ========================================
userStore := auth.NewDBUserStore(s.db)
authService := auth.NewService(userStore)
authHandlers := web.NewAuthHandlers(authService)
e.GET("/", s.HelloWorldHandler) // ========================================
// API Routes (JSON responses) - Health only, products/invoice via web UI
// ========================================
apiHandlers := api.NewHandlers(s.db)
apiGroup := e.Group("/api")
{
apiGroup.GET("/health", apiHandlers.HealthHandler)
}
e.GET("/health", s.healthHandler) // ========================================
// Public Web Routes (no auth required)
// ========================================
e.GET("/", authHandlers.LoginPageHandler)
e.POST("/login", authHandlers.LoginHandler)
e.GET("/register", authHandlers.RegisterPageHandler)
e.POST("/register", authHandlers.RegisterHandler)
e.GET("/logout", authHandlers.LogoutHandler)
// ========================================
// Protected Web Routes (auth required)
// ========================================
protected := e.Group("")
protected.Use(authHandlers.AuthMiddleware)
// Home
homeHandlers := web.NewHomeHandlers(s.db)
protected.GET("/home", homeHandlers.HomePageHandler)
// Account routes
accountHandlers := web.NewAccountHandlers(s.db, authService)
protected.GET("/account", accountHandlers.AccountPageHandler)
protected.POST("/account/details", accountHandlers.UpdateDetailsHandler)
protected.POST("/account/password", accountHandlers.ChangePasswordHandler)
// Buyer routes
buyerHandlers := web.NewBuyerHandlers(s.db)
protected.GET("/buyer", buyerHandlers.BuyerListHandler)
protected.GET("/buyer/create", buyerHandlers.BuyerCreatePageHandler)
protected.POST("/buyer/create", buyerHandlers.BuyerCreateHandler)
protected.GET("/buyer/edit/:id", buyerHandlers.BuyerEditPageHandler)
protected.POST("/buyer/edit/:id", buyerHandlers.BuyerUpdateHandler)
protected.DELETE("/buyer/:id", buyerHandlers.BuyerDeleteHandler)
// Invoices list
invoicesHandlers := web.NewInvoicesHandlers(s.db)
protected.GET("/invoice", invoicesHandlers.InvoicesListHandler)
// Product routes (web UI)
productHandlers := web.NewProductHandlers(s.db)
protected.GET("/product", productHandlers.ProductListHandler)
protected.GET("/product/create", productHandlers.ProductCreatePageHandler)
protected.POST("/product/create", productHandlers.ProductCreateHandler)
protected.GET("/product/edit/:sku", productHandlers.ProductEditPageHandler)
protected.POST("/product/edit/:sku", productHandlers.ProductUpdateHandler)
protected.DELETE("/product/:sku", productHandlers.ProductDeleteHandler)
// Billing routes (web UI)
billingHandlers := web.NewBillingHandlers(s.db)
protected.GET("/billing", billingHandlers.BillingPageHandler)
protected.POST("/billing/calculate", billingHandlers.CalculateBillHandler)
protected.POST("/billing/generate", billingHandlers.GenerateBillHandler)
protected.GET("/billing/add-row", billingHandlers.AddProductRowHandler)
// Invoice view (protected - only owner can view)
protected.GET("/invoice/:id", billingHandlers.ShowInvoiceHandler)
// Legacy health check (kept for backward compatibility)
e.GET("/health", apiHandlers.HealthHandler)
// Custom 404 handler for Echo HTTP errors
e.HTTPErrorHandler = func(err error, c echo.Context) {
if he, ok := err.(*echo.HTTPError); ok {
switch he.Code {
case http.StatusNotFound:
_ = web.RenderNotFound(c, "")
return
case http.StatusInternalServerError:
_ = web.RenderServerError(c, "")
return
}
}
// Default error handler for other cases
e.DefaultHTTPErrorHandler(err, c)
}
// Catch-all for undefined routes (must be last)
e.RouteNotFound("/*", func(c echo.Context) error {
return web.RenderNotFound(c, "")
})
return e return e
} }
func (s *Server) HelloWorldHandler(c echo.Context) error {
resp := map[string]string{
"message": "Hello World",
}
return c.JSON(http.StatusOK, resp)
}
func (s *Server) healthHandler(c echo.Context) error {
return c.JSON(http.StatusOK, s.db.Health())
}

View File

@@ -1,39 +1,34 @@
package server package server
import ( import (
"encoding/json"
"github.com/labstack/echo/v4"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"reflect"
"testing" "testing"
"billit/internal/database"
"github.com/labstack/echo/v4"
) )
func TestHandler(t *testing.T) { func TestHomeRoute(t *testing.T) {
e := echo.New() // Create a minimal server with db for testing
db := database.New()
s := &Server{db: db}
handler := s.RegisterRoutes()
req := httptest.NewRequest(http.MethodGet, "/", nil) req := httptest.NewRequest(http.MethodGet, "/", nil)
resp := httptest.NewRecorder() resp := httptest.NewRecorder()
c := e.NewContext(req, resp) handler.ServeHTTP(resp, req)
s := &Server{}
// Assertions
if err := s.HelloWorldHandler(c); err != nil {
t.Errorf("handler() error = %v", err)
return
}
if resp.Code != http.StatusOK { if resp.Code != http.StatusOK {
t.Errorf("handler() wrong status code = %v", resp.Code) t.Errorf("home route wrong status code = %v, want %v", resp.Code, http.StatusOK)
return }
} }
expected := map[string]string{"message": "Hello World"}
var actual map[string]string func TestRouterSetup(t *testing.T) {
// Decode the response body into the actual map // Test that Echo router can be set up without panic
if err := json.NewDecoder(resp.Body).Decode(&actual); err != nil { e := echo.New()
t.Errorf("handler() error decoding response body: %v", err) if e == nil {
return t.Error("failed to create echo instance")
}
// Compare the decoded response with the expected value
if !reflect.DeepEqual(expected, actual) {
t.Errorf("handler() wrong response body. expected = %v, actual = %v", expected, actual)
return
} }
} }

107
internal/web/account.go Normal file
View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,249 @@
// 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();
}
})();

View File

@@ -0,0 +1,116 @@
// ============================================
// BILLIT - Base/Reset Styles
// ============================================
@use 'variables' as *;
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: $font-family-base;
font-size: $font-size-base;
line-height: $line-height-base;
color: $color-gray-900;
background-color: $color-white;
min-height: 100vh;
}
// Typography
h1, h2, h3, h4, h5, h6 {
font-weight: $font-weight-bold;
line-height: $line-height-tight;
margin: 0;
}
h1 { font-size: $font-size-2xl; }
h2 { font-size: $font-size-xl; }
h3 { font-size: $font-size-lg; }
h4 { font-size: $font-size-md; }
h5 { font-size: $font-size-base; }
h6 { font-size: $font-size-sm; }
p {
margin: 0;
}
a {
color: $color-accent;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
// Lists
ul, ol {
list-style: none;
}
// Images
img {
max-width: 100%;
height: auto;
}
// Tables
table {
border-collapse: collapse;
width: 100%;
}
// Forms
input, select, textarea, button {
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
// Focus styles
:focus {
outline: 2px solid $color-accent;
outline-offset: 1px;
}
:focus:not(:focus-visible) {
outline: none;
}
:focus-visible {
outline: 2px solid $color-accent;
outline-offset: 1px;
}
// Selection
::selection {
background: $color-primary;
color: $color-white;
}
// Scrollbar (webkit)
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: $color-gray-100;
}
::-webkit-scrollbar-thumb {
background: $color-gray-400;
&:hover {
background: $color-gray-500;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
// ============================================
// BILLIT - Print Styles
// ============================================
@use 'variables' as *;
@media print {
// Hide non-printable elements
.no-print,
.header,
.btn,
button {
display: none !important;
visibility: hidden !important;
}
// Reset backgrounds
body {
background: white !important;
color: black !important;
font-size: 11pt;
}
// Page setup
@page {
margin: 1cm;
size: A4;
}
// Page breaks
.page-break {
page-break-before: always;
}
.avoid-break {
page-break-inside: avoid;
}
// Links
a {
color: black !important;
text-decoration: none !important;
}
// Tables
table {
border-collapse: collapse;
}
th, td {
border: 1px solid #000 !important;
}
// Invoice specific
.invoice {
max-width: 100%;
padding: 0;
margin: 0;
}
.invoice-header {
border-bottom: 2px solid black;
}
.invoice-table {
th {
background: #e0e0e0 !important;
color: black !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
.invoice-totals {
.row-total {
background: #e0e0e0 !important;
color: black !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
// QR code footer
.invoice-qr {
position: fixed;
bottom: 0;
left: 0;
right: 0;
text-align: center;
padding: 10px;
border-top: 1px solid #ccc;
}
}

View File

@@ -0,0 +1,197 @@
// ============================================
// BILLIT - Utility Classes
// ============================================
@use 'variables' as *;
// Display
.hidden { display: none !important; }
.block { display: block; }
.inline { display: inline; }
.inline-block { display: inline-block; }
.flex { display: flex; }
.inline-flex { display: inline-flex; }
.grid { display: grid; }
// Flex utilities
.flex-row { flex-direction: row; }
.flex-col { flex-direction: column; }
.flex-wrap { flex-wrap: wrap; }
.flex-1 { flex: 1; }
.flex-grow { flex-grow: 1; }
.flex-shrink-0 { flex-shrink: 0; }
.items-start { align-items: flex-start; }
.items-center { align-items: center; }
.items-end { align-items: flex-end; }
.items-stretch { align-items: stretch; }
.justify-start { justify-content: flex-start; }
.justify-center { justify-content: center; }
.justify-end { justify-content: flex-end; }
.justify-between { justify-content: space-between; }
.gap-1 { gap: $spacing-1; }
.gap-2 { gap: $spacing-2; }
.gap-3 { gap: $spacing-3; }
.gap-4 { gap: $spacing-4; }
.gap-6 { gap: $spacing-6; }
.gap-8 { gap: $spacing-8; }
// Text
.text-xs { font-size: $font-size-xs; }
.text-sm { font-size: $font-size-sm; }
.text-base { font-size: $font-size-base; }
.text-md { font-size: $font-size-md; }
.text-lg { font-size: $font-size-lg; }
.text-xl { font-size: $font-size-xl; }
.text-2xl { font-size: $font-size-2xl; }
.text-3xl { font-size: $font-size-3xl; }
.font-normal { font-weight: $font-weight-normal; }
.font-medium { font-weight: $font-weight-medium; }
.font-semibold { font-weight: $font-weight-semibold; }
.font-bold { font-weight: $font-weight-bold; }
.text-left { text-align: left; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.uppercase { text-transform: uppercase; }
.lowercase { text-transform: lowercase; }
.capitalize { text-transform: capitalize; }
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.whitespace-nowrap { white-space: nowrap; }
// Colors
.text-black { color: $color-black; }
.text-white { color: $color-white; }
.text-gray { color: $color-gray-600; }
.text-gray-dark { color: $color-gray-800; }
.text-gray-light { color: $color-gray-500; }
.text-primary { color: $color-primary; }
.text-accent { color: $color-accent; }
.text-success { color: $color-success; }
.text-warning { color: $color-warning; }
.text-error { color: $color-error; }
.bg-white { background-color: $color-white; }
.bg-gray-50 { background-color: $color-gray-50; }
.bg-gray-100 { background-color: $color-gray-100; }
.bg-gray-200 { background-color: $color-gray-200; }
.bg-black { background-color: $color-black; }
.bg-primary { background-color: $color-primary; }
// Spacing - Margin
.m-0 { margin: $spacing-0; }
.m-2 { margin: $spacing-2; }
.m-4 { margin: $spacing-4; }
.m-8 { margin: $spacing-8; }
.mt-0 { margin-top: $spacing-0; }
.mt-2 { margin-top: $spacing-2; }
.mt-4 { margin-top: $spacing-4; }
.mt-6 { margin-top: $spacing-6; }
.mt-8 { margin-top: $spacing-8; }
.mt-12 { margin-top: $spacing-12; }
.mb-0 { margin-bottom: $spacing-0; }
.mb-2 { margin-bottom: $spacing-2; }
.mb-4 { margin-bottom: $spacing-4; }
.mb-6 { margin-bottom: $spacing-6; }
.mb-8 { margin-bottom: $spacing-8; }
.ml-2 { margin-left: $spacing-2; }
.ml-4 { margin-left: $spacing-4; }
.mr-2 { margin-right: $spacing-2; }
.mr-4 { margin-right: $spacing-4; }
.mx-auto { margin-left: auto; margin-right: auto; }
// Spacing - Padding
.p-0 { padding: $spacing-0; }
.p-2 { padding: $spacing-2; }
.p-3 { padding: $spacing-3; }
.p-4 { padding: $spacing-4; }
.p-6 { padding: $spacing-6; }
.p-8 { padding: $spacing-8; }
.px-2 { padding-left: $spacing-2; padding-right: $spacing-2; }
.px-4 { padding-left: $spacing-4; padding-right: $spacing-4; }
.px-6 { padding-left: $spacing-6; padding-right: $spacing-6; }
.px-8 { padding-left: $spacing-8; padding-right: $spacing-8; }
.py-2 { padding-top: $spacing-2; padding-bottom: $spacing-2; }
.py-3 { padding-top: $spacing-3; padding-bottom: $spacing-3; }
.py-4 { padding-top: $spacing-4; padding-bottom: $spacing-4; }
.py-6 { padding-top: $spacing-6; padding-bottom: $spacing-6; }
.py-8 { padding-top: $spacing-8; padding-bottom: $spacing-8; }
// Width/Height
.w-full { width: 100%; }
.w-auto { width: auto; }
.h-full { height: 100%; }
.h-screen { height: 100vh; }
.min-h-screen { min-height: 100vh; }
// Borders
.border { border: $border-width solid $border-color; }
.border-0 { border: none; }
.border-t { border-top: $border-width solid $border-color; }
.border-b { border-bottom: $border-width solid $border-color; }
.border-l { border-left: $border-width solid $border-color; }
.border-r { border-right: $border-width solid $border-color; }
.border-dark { border-color: $border-color-dark; }
.border-2 { border-width: $border-width-2; }
// Position
.relative { position: relative; }
.absolute { position: absolute; }
.fixed { position: fixed; }
.sticky { position: sticky; }
.top-0 { top: 0; }
.right-0 { right: 0; }
.bottom-0 { bottom: 0; }
.left-0 { left: 0; }
.inset-0 { top: 0; right: 0; bottom: 0; left: 0; }
// Z-index
.z-10 { z-index: 10; }
.z-20 { z-index: 20; }
.z-50 { z-index: 50; }
.z-100 { z-index: 100; }
// Overflow
.overflow-hidden { overflow: hidden; }
.overflow-auto { overflow: auto; }
.overflow-x-auto { overflow-x: auto; }
.overflow-y-auto { overflow-y: auto; }
// Shadows
.shadow { box-shadow: $shadow-sm; }
.shadow-md { box-shadow: $shadow-md; }
.shadow-lg { box-shadow: $shadow-lg; }
.shadow-none { box-shadow: none; }
// Cursor
.cursor-pointer { cursor: pointer; }
.cursor-default { cursor: default; }
// Opacity
.opacity-50 { opacity: 0.5; }
.opacity-75 { opacity: 0.75; }
// Print utilities
@media print {
.no-print { display: none !important; }
.print-only { display: block !important; }
}
.print-only { display: none; }

View File

@@ -0,0 +1,116 @@
// ============================================
// BILLIT - McMaster-Carr Inspired Design System
// Industrial, Dense, Functional, No Roundedness
// ============================================
// Colors - Industrial palette
$color-black: #000000;
$color-white: #ffffff;
$color-gray-50: #fafafa;
$color-gray-100: #f5f5f5;
$color-gray-200: #eeeeee;
$color-gray-300: #e0e0e0;
$color-gray-400: #bdbdbd;
$color-gray-500: #9e9e9e;
$color-gray-600: #757575;
$color-gray-700: #616161;
$color-gray-800: #424242;
$color-gray-900: #212121;
// Primary - Industrial orange (McMaster signature)
$color-primary: #e65100;
$color-primary-dark: #bf360c;
$color-primary-light: #ff6d00;
// Accent - Deep blue for links/actions
$color-accent: #0d47a1;
$color-accent-dark: #002171;
$color-accent-light: #1565c0;
// Status colors
$color-success: #2e7d32;
$color-warning: #f57c00;
$color-error: #c62828;
$color-info: #1565c0;
// Typography
$font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
$font-family-mono: "SF Mono", "Monaco", "Inconsolata", "Fira Mono", "Droid Sans Mono", monospace;
$font-size-xs: 0.6875rem; // 11px
$font-size-sm: 0.75rem; // 12px
$font-size-base: 0.8125rem; // 13px
$font-size-md: 0.875rem; // 14px
$font-size-lg: 1rem; // 16px
$font-size-xl: 1.125rem; // 18px
$font-size-2xl: 1.25rem; // 20px
$font-size-3xl: 1.5rem; // 24px
$font-weight-normal: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
$font-weight-bold: 700;
$line-height-tight: 1.2;
$line-height-base: 1.4;
$line-height-loose: 1.6;
// Spacing - Dense, compact
$spacing-0: 0;
$spacing-1: 0.125rem; // 2px
$spacing-2: 0.25rem; // 4px
$spacing-3: 0.375rem; // 6px
$spacing-4: 0.5rem; // 8px
$spacing-5: 0.625rem; // 10px
$spacing-6: 0.75rem; // 12px
$spacing-8: 1rem; // 16px
$spacing-10: 1.25rem; // 20px
$spacing-12: 1.5rem; // 24px
$spacing-16: 2rem; // 32px
$spacing-20: 2.5rem; // 40px
// Borders - Sharp, no radius
$border-width: 1px;
$border-width-2: 2px;
$border-color: $color-gray-300;
$border-color-dark: $color-gray-400;
$border-radius: 0; // NO ROUNDEDNESS
// Shadows - Minimal
$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.1);
$shadow-md: 0 2px 4px rgba(0, 0, 0, 0.1);
$shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.15);
// Transitions
$transition-fast: 0.1s ease;
$transition-base: 0.15s ease;
$transition-slow: 0.25s ease;
// Layout
$max-width-sm: 640px;
$max-width-md: 768px;
$max-width-lg: 1024px;
$max-width-xl: 1280px;
$max-width-2xl: 1536px;
// Header
$header-height: 40px;
$header-bg: $color-black;
$header-text: $color-white;
// Table
$table-header-bg: $color-gray-100;
$table-border: $color-gray-300;
$table-row-hover: $color-gray-50;
$table-cell-padding: $spacing-3 $spacing-4;
// Form inputs
$input-height: 28px;
$input-padding: $spacing-2 $spacing-4;
$input-border: $border-color;
$input-focus-border: $color-accent;
$input-bg: $color-white;
// Buttons
$btn-height: 28px;
$btn-padding: $spacing-2 $spacing-6;

View File

@@ -0,0 +1,10 @@
// ============================================
// BILLIT - Main SCSS Entry Point
// McMaster-Carr Inspired Design System
// ============================================
@use 'variables' as *;
@use 'base';
@use 'utilities';
@use 'components';
@use 'print';

138
internal/web/auth.go Normal file
View File

@@ -0,0 +1,138 @@
package web
import (
"billit/internal/auth"
"net/http"
"net/url"
"strings"
"github.com/labstack/echo/v4"
)
// AuthHandlers holds auth service reference
type AuthHandlers struct {
auth *auth.Service
}
// NewAuthHandlers creates handlers with auth service
func NewAuthHandlers(authService *auth.Service) *AuthHandlers {
return &AuthHandlers{auth: authService}
}
// LoginPageHandler renders the login page (home page)
func (h *AuthHandlers) LoginPageHandler(c echo.Context) error {
// Check if already logged in
cookie, err := c.Cookie("auth_token")
if err == nil && cookie.Value != "" {
_, err := h.auth.ValidateToken(cookie.Value)
if err == nil {
// Already logged in, redirect to home
return c.Redirect(http.StatusFound, "/home")
}
}
// Capture redirect URL from query param
redirectURL := c.QueryParam("redirect")
return Render(c, LoginPage("", "", redirectURL))
}
// LoginHandler handles login form submission
func (h *AuthHandlers) LoginHandler(c echo.Context) error {
email := strings.TrimSpace(c.FormValue("email"))
password := c.FormValue("password")
redirectURL := c.FormValue("redirect")
if email == "" || password == "" {
return Render(c, LoginPage("Email and password are required", email, redirectURL))
}
token, err := h.auth.Login(email, password)
if err != nil {
return Render(c, LoginPage("Invalid email or password", email, redirectURL))
}
// Set HTTP-only cookie
cookie := h.auth.CreateAuthCookie(token)
c.SetCookie(cookie)
// Redirect to original URL or home page
if redirectURL != "" && strings.HasPrefix(redirectURL, "/") {
return c.Redirect(http.StatusFound, redirectURL)
}
return c.Redirect(http.StatusFound, "/home")
}
// RegisterPageHandler renders the registration page
func (h *AuthHandlers) RegisterPageHandler(c echo.Context) error {
return Render(c, RegisterPage("", ""))
}
// RegisterHandler handles registration form submission
func (h *AuthHandlers) RegisterHandler(c echo.Context) error {
email := strings.TrimSpace(c.FormValue("email"))
password := c.FormValue("password")
confirmPassword := c.FormValue("confirm_password")
if email == "" || password == "" {
return Render(c, RegisterPage("Email and password are required", email))
}
if password != confirmPassword {
return Render(c, RegisterPage("Passwords do not match", email))
}
if len(password) < 8 {
return Render(c, RegisterPage("Password must be at least 8 characters", email))
}
_, err := h.auth.Register(email, password)
if err != nil {
if err == auth.ErrUserExists {
return Render(c, RegisterPage("An account with this email already exists", email))
}
return Render(c, RegisterPage(err.Error(), email))
}
// Auto-login after registration
token, err := h.auth.Login(email, password)
if err != nil {
return c.Redirect(http.StatusFound, "/")
}
cookie := h.auth.CreateAuthCookie(token)
c.SetCookie(cookie)
return c.Redirect(http.StatusFound, "/home")
}
// LogoutHandler clears the auth cookie and redirects to login
func (h *AuthHandlers) LogoutHandler(c echo.Context) error {
cookie := h.auth.ClearAuthCookie()
c.SetCookie(cookie)
return c.Redirect(http.StatusFound, "/")
}
// AuthMiddleware protects routes that require authentication
func (h *AuthHandlers) AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
cookie, err := c.Cookie("auth_token")
if err != nil || cookie.Value == "" {
// No cookie - redirect to login with original URL for post-login redirect
redirectPath := url.QueryEscape(c.Request().URL.RequestURI())
return c.Redirect(http.StatusFound, "/?redirect="+redirectPath)
}
claims, err := h.auth.ValidateToken(cookie.Value)
if err != nil {
// Invalid/expired token - show session expired dialog
c.SetCookie(h.auth.ClearAuthCookie())
redirectPath := url.QueryEscape(c.Request().URL.RequestURI())
return Render(c, SessionExpiredPage(redirectPath))
}
// Store user info in context
c.Set("user_id", claims.UserID)
c.Set("user_email", claims.Email)
return next(c)
}
}

434
internal/web/billing.go Normal file
View File

@@ -0,0 +1,434 @@
package web
import (
"billit/internal/database"
"billit/internal/gst"
"encoding/base64"
"encoding/json"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
qrcode "github.com/skip2/go-qrcode"
)
// numberToWords converts a number to Indian English words (supports up to crores)
func numberToWords(n float64) string {
if n == 0 {
return "Zero"
}
ones := []string{"", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine",
"Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen"}
tens := []string{"", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"}
convertLessThanHundred := func(n int) string {
if n < 20 {
return ones[n]
}
if n%10 == 0 {
return tens[n/10]
}
return tens[n/10] + " " + ones[n%10]
}
convertLessThanThousand := func(n int) string {
if n < 100 {
return convertLessThanHundred(n)
}
if n%100 == 0 {
return ones[n/100] + " Hundred"
}
return ones[n/100] + " Hundred " + convertLessThanHundred(n%100)
}
// Split into rupees and paise
rupees := int(math.Floor(n))
paise := int(math.Round((n - float64(rupees)) * 100))
var result string
if rupees >= 10000000 { // Crores
crores := rupees / 10000000
rupees = rupees % 10000000
result += convertLessThanThousand(crores) + " Crore "
}
if rupees >= 100000 { // Lakhs
lakhs := rupees / 100000
rupees = rupees % 100000
result += convertLessThanHundred(lakhs) + " Lakh "
}
if rupees >= 1000 { // Thousands
thousands := rupees / 1000
rupees = rupees % 1000
result += convertLessThanHundred(thousands) + " Thousand "
}
if rupees > 0 {
result += convertLessThanThousand(rupees)
}
result = strings.TrimSpace(result)
if result == "" {
result = "Zero"
}
result += " Rupees"
if paise > 0 {
result += " and " + convertLessThanHundred(paise) + " Paise"
}
return result + " Only"
}
// BillingHandlers holds db reference for billing operations
type BillingHandlers struct {
db database.Service
}
// NewBillingHandlers creates handlers with db access
func NewBillingHandlers(db database.Service) *BillingHandlers {
return &BillingHandlers{db: db}
}
// BillingPageHandler renders the /billing page for creating bills
func (h *BillingHandlers) BillingPageHandler(c echo.Context) error {
userID := getUserID(c)
products, err := h.db.GetAllProducts(userID)
if err != nil {
products = []database.Product{}
}
buyers, err := h.db.GetAllBuyerDetails(userID)
if err != nil {
buyers = []database.BuyerDetails{}
}
return Render(c, BillingPage(products, buyers))
}
// CalculateBillHandler calculates the bill (HTMX endpoint)
func (h *BillingHandlers) CalculateBillHandler(c echo.Context) error {
userID := getUserID(c)
customerType := c.FormValue("customer_type")
regionType := c.FormValue("region_type")
includeConvenienceFee := c.FormValue("include_convenience_fee") == "yes"
cType := gst.CustomerRetail
if customerType == "wholesale" {
cType = gst.CustomerWholesale
}
isInterState := regionType == "inter"
calculator := gst.NewCalculator()
var items []gst.LineItem
var totalFee float64
// Support up to 50 product slots for dynamic adding
for i := 0; i < 50; i++ {
sku := c.FormValue("product_sku_" + strconv.Itoa(i))
qtyStr := c.FormValue("qty_" + strconv.Itoa(i))
if sku == "" {
continue
}
qty, err := strconv.Atoi(qtyStr)
if err != nil || qty <= 0 {
continue
}
// Get product from DB (user-scoped)
dbProduct, err := h.db.GetProductBySKU(sku, userID)
if err != nil || dbProduct == nil {
continue
}
// Convert to gst.Product
product := gst.Product{
SKU: dbProduct.SKU,
Name: dbProduct.Name,
HSNCode: dbProduct.HSNCode,
BasePrice: dbProduct.BasePrice,
WholesalePrice: dbProduct.WholesalePrice,
GSTRate: gst.Rate(dbProduct.GSTRate),
SmallOrderQty: dbProduct.SmallOrderQty,
SmallOrderFee: dbProduct.SmallOrderFee,
Unit: dbProduct.Unit,
}
lineItem := calculator.CalculateLineItem(product, qty, cType, isInterState)
items = append(items, lineItem)
// Apply per-product convenience fee if quantity is below threshold and checkbox is checked
if includeConvenienceFee && product.SmallOrderQty > 0 && qty < product.SmallOrderQty && product.SmallOrderFee > 0 {
totalFee += product.SmallOrderFee
}
}
invoice := calculator.CalculateInvoice(items, totalFee, isInterState)
invoice.CustomerType = cType
return Render(c, InvoiceSummary(invoice))
}
// GenerateBillHandler generates final invoice with UUID and persists to DB
func (h *BillingHandlers) GenerateBillHandler(c echo.Context) error {
userID := getUserID(c)
if userID == "" {
return c.Redirect(http.StatusFound, "/")
}
customerType := c.FormValue("customer_type")
regionType := c.FormValue("region_type")
buyerID := c.FormValue("buyer_id")
includeConvenienceFee := c.FormValue("include_convenience_fee") == "yes"
cType := gst.CustomerRetail
if customerType == "wholesale" {
cType = gst.CustomerWholesale
}
isInterState := regionType == "inter"
calculator := gst.NewCalculator()
var items []gst.LineItem
var totalFee float64
for i := 0; i < 50; i++ {
sku := c.FormValue("product_sku_" + strconv.Itoa(i))
qtyStr := c.FormValue("qty_" + strconv.Itoa(i))
if sku == "" {
continue
}
qty, err := strconv.Atoi(qtyStr)
if err != nil || qty <= 0 {
continue
}
dbProduct, err := h.db.GetProductBySKU(sku, userID)
if err != nil || dbProduct == nil {
continue
}
product := gst.Product{
SKU: dbProduct.SKU,
Name: dbProduct.Name,
HSNCode: dbProduct.HSNCode,
BasePrice: dbProduct.BasePrice,
WholesalePrice: dbProduct.WholesalePrice,
GSTRate: gst.Rate(dbProduct.GSTRate),
SmallOrderQty: dbProduct.SmallOrderQty,
SmallOrderFee: dbProduct.SmallOrderFee,
Unit: dbProduct.Unit,
}
lineItem := calculator.CalculateLineItem(product, qty, cType, isInterState)
items = append(items, lineItem)
// Apply per-product convenience fee if checkbox is checked
if includeConvenienceFee && product.SmallOrderQty > 0 && qty < product.SmallOrderQty && product.SmallOrderFee > 0 {
totalFee += product.SmallOrderFee
}
}
if len(items) == 0 {
return c.String(http.StatusBadRequest, "No products selected")
}
invoice := calculator.CalculateInvoice(items, totalFee, isInterState)
invoice.CustomerType = cType
// Get user's company and bank details
user, err := h.db.GetUserByID(userID)
if err == nil && user != nil {
invoice.CompanyDetails = user.CompanyDetails
invoice.BankDetails = user.BankDetails
}
// Get buyer details if selected
if buyerID != "" {
buyer, err := h.db.GetBuyerDetails(buyerID, userID)
if err == nil && buyer != nil {
invoice.BuyerName = buyer.Name
invoice.BuyerDetails = buyer.Details
}
}
// Generate UUID for invoice
invoiceID := uuid.New().String()
// Generate human-readable invoice ID
humanReadableID, err := h.db.GetNextInvoiceNumber(userID)
if err != nil {
return c.String(http.StatusInternalServerError, "failed to generate invoice number")
}
// Persist to DB (user-scoped)
if err := h.db.CreateInvoice(invoiceID, humanReadableID, invoice, userID); err != nil {
return c.String(http.StatusInternalServerError, "failed to save invoice")
}
// Redirect to invoice view
c.Response().Header().Set("HX-Redirect", fmt.Sprintf("/invoice/%s", invoiceID))
return c.NoContent(http.StatusOK)
}
// ShowInvoiceHandler displays the invoice by UUID (requires auth)
func (h *BillingHandlers) ShowInvoiceHandler(c echo.Context) error {
userID := getUserID(c)
if userID == "" {
return c.Redirect(http.StatusFound, "/")
}
invoiceID := c.Param("id")
inv, err := h.db.GetInvoice(invoiceID, userID)
if err != nil || inv == nil {
return RenderNotFound(c, "Invoice not found or you don't have access to it.")
}
// Parse the JSON data back into Invoice struct
var invoice gst.Invoice
if err := json.Unmarshal([]byte(inv.Data), &invoice); err != nil {
return c.String(http.StatusInternalServerError, "failed to parse invoice data")
}
// Generate QR code for invoice URL
invoiceURL := fmt.Sprintf("%s://%s/invoice/%s", c.Scheme(), c.Request().Host, invoiceID)
qrPNG, err := qrcode.Encode(invoiceURL, qrcode.Medium, 100)
if err != nil {
return c.String(http.StatusInternalServerError, "failed to generate QR code")
}
qrBase64 := base64.StdEncoding.EncodeToString(qrPNG)
// Render printable invoice page with multi-page support
c.Response().Header().Set(echo.HeaderContentType, echo.MIMETextHTML)
w := c.Response().Writer
fmt.Fprint(w, "<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>Invoice ")
fmt.Fprintf(w, "%s</title>", invoiceID[:8])
fmt.Fprint(w, "<link href='/assets/css/output.css' rel='stylesheet'>")
fmt.Fprint(w, `<style type="text/css">
@media print {
.no-print { display: none !important; }
.page-break { page-break-before: always; }
@page { margin: 1cm; size: A4; }
body { background: white !important; }
.invoice-table th { background: #e0e0e0 !important; color: black !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.invoice-totals .row-total { background: #e0e0e0 !important; color: black !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
.invoice-details-block { white-space: pre-wrap; font-size: 0.875rem; line-height: 1.4; }
.invoice-footer-section { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid #e0e0e0; }
</style>`)
fmt.Fprint(w, "</head><body>")
fmt.Fprintf(w, `<div class="container page">
<div class="no-print" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1.5rem;padding-bottom:1rem;border-bottom:1px solid #e0e0e0;">
<a href="/invoice" class="text-accent">&larr; Back</a>
<button onclick="window.print()" class="btn btn-primary">Print Invoice</button>
</div>
<div class="invoice">
<div class="invoice-header">
<div>
<h1 class="invoice-title">Tax Invoice</h1>
</div>
<div class="invoice-meta">
<p>Invoice ID: %s</p>
</div>
<div class="invoice-meta">
<p>Date: %s</p>
</div>
</div>`, inv.HumanReadableID, strings.ReplaceAll(inv.CreatedAt, "T", " ")[0:10])
// Display company details above the invoice table
if invoice.CompanyDetails != "" {
fmt.Fprintf(w, `<div class="invoice-details-block" style="margin-bottom:1rem;"><strong>From:</strong><br>%s</div>`, invoice.CompanyDetails)
}
if err := PrintableInvoice(invoice).Render(c.Request().Context(), w); err != nil {
return err
}
// Display buyer details and total amount in same section (50-50 split or 100% if no buyer)
totalGST := invoice.TotalCGST + invoice.TotalSGST + invoice.TotalIGST
hasBuyerInfo := invoice.BuyerName != "" || invoice.BuyerDetails != ""
if hasBuyerInfo {
fmt.Fprint(w, `<div class="invoice-footer-section" style="display:flex;justify-content:space-between;gap:2rem;">`)
// Left side: Billed To (50%)
fmt.Fprint(w, `<div style="flex:1;"><strong>Billed To:</strong><br>`)
if invoice.BuyerName != "" {
fmt.Fprintf(w, `<span class="invoice-details-block">%s</span>`, invoice.BuyerName)
}
if invoice.BuyerDetails != "" {
fmt.Fprintf(w, `<br><span class="invoice-details-block">%s</span>`, invoice.BuyerDetails)
}
fmt.Fprint(w, `</div>`)
// Right side: Total Amount (50%)
fmt.Fprintf(w, `<div style="flex:1;">
<p style="margin: 0.5rem 0;"><strong>Total Amount (before GST):</strong><br>%s</p>
<p style="margin: 0.5rem 0;"><strong>GST Amount:</strong><br>%s</p>
</div>`, numberToWords(invoice.SubTotal), numberToWords(totalGST))
} else {
fmt.Fprint(w, `<div class="invoice-footer-section">`)
// Total Amount takes 100%
fmt.Fprintf(w, `<div>
<p style="margin: 0.5rem 0;"><strong>Total Amount (before GST):</strong><br>%s</p>
<p style="margin: 0.5rem 0;"><strong>GST Amount:</strong><br>%s</p>
</div>`, numberToWords(invoice.SubTotal), numberToWords(totalGST))
}
fmt.Fprint(w, `</div>`)
// Bank details (left) and QR code (right) in the same section
fmt.Fprint(w, `<div class="invoice-footer-section" style="display:flex;justify-content:space-between;align-items:flex-start;">`)
if invoice.BankDetails != "" {
fmt.Fprintf(w, `<div style="flex:1;"><strong>Bank Details:</strong><br><span class="invoice-details-block">%s</span></div>`, invoice.BankDetails)
} else {
fmt.Fprint(w, `<div style="flex:1;"></div>`)
}
fmt.Fprintf(w, `<div style="margin-left:1rem;"><img src="data:image/png;base64,%s" alt="QR Code" style="width:80px;height:80px;"></div>`, qrBase64)
fmt.Fprint(w, `</div>`)
fmt.Fprint(w, "</div>")
fmt.Fprint(w, "</div></body></html>")
return nil
}
// AddProductRowHandler returns HTML for a new product row (HTMX endpoint)
func (h *BillingHandlers) AddProductRowHandler(c echo.Context) error {
userID := getUserID(c)
indexStr := c.QueryParam("index")
index, err := strconv.Atoi(indexStr)
if err != nil {
index = 0
}
products, _ := h.db.GetAllProducts(userID)
// Build product options HTML
productOptions := `<option value="">-- Select Product --</option>`
for _, p := range products {
productOptions += fmt.Sprintf(`<option value="%s">%s (₹%.2f)</option>`, p.SKU, p.Name, p.BasePrice)
}
rowHTML := fmt.Sprintf(`
<div class="product-row">
<div class="product-row-grid">
<div class="form-group" style="margin:0;">
<label class="form-label">Product</label>
<select name="product_sku_%d" class="form-select">%s</select>
</div>
<div class="form-group" style="margin:0;">
<label class="form-label">Qty</label>
<input type="number" name="qty_%d" value="1" min="0" class="form-input">
</div>
<div style="padding-top:20px;">
<button type="button" onclick="this.closest('.product-row').remove()" class="btn btn-danger btn-sm">×</button>
</div>
</div>
</div>`, index, productOptions, index)
return c.HTML(http.StatusOK, rowHTML)
}

106
internal/web/buyer.go Normal file
View File

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

37
internal/web/home.go Normal file
View File

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

32
internal/web/invoices.go Normal file
View File

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

231
internal/web/product.go Normal file
View File

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

12
internal/web/render.go Normal file
View File

@@ -0,0 +1,12 @@
package web
import (
"github.com/a-h/templ"
"github.com/labstack/echo/v4"
)
// Render wraps templ component rendering for Echo
func Render(c echo.Context, component templ.Component) error {
c.Response().Header().Set(echo.HeaderContentType, echo.MIMETextHTML)
return component.Render(c.Request().Context(), c.Response().Writer)
}

View File

@@ -1,5 +0,0 @@
module.exports = {
content: ["./**/*.html", "./**/*.templ", "./**/*.go",],
theme: { extend: {}, },
plugins: [],
}