Skip to main content

Welcome Contributors!

Thank you for your interest in contributing to POS Kasir! This guide will help you set up your development environment and understand the development workflow.

Prerequisites

Before you begin, ensure you have the following installed:

Required

  • Docker & Docker Compose - Install
  • sqlc - Install
  • golang-migrate - Install
  • Air (Go hot reload) - Install
  • Make (usually pre-installed on Linux/macOS)

Development Tools

IDE Recommendations: Useful VS Code Extensions:
  • Go (golang.go)
  • PostgreSQL (ckolkman.vscode-postgres)
  • Docker (ms-azuretools.vscode-docker)
  • Thunder Client (REST API testing)

Getting Started

1. Clone the Repository

git clone https://github.com/agpprastyo/POS-kasir.git
cd POS-kasir

2. Set Up Environment Variables

Backend Configuration

cp .env.example .env
Edit .env with your settings:
# Server Configuration
APP_ENV=development
APP_NAME="POS Kasir"
SERVER_PORT=8080
CORS_ALLOW_ORIGINS=http://localhost:3000

# Database Configuration
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=your_password
DB_NAME=pos_kasir
DB_SSLMODE=disable

# JWT Configuration
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_REFRESH_SECRET=your-refresh-secret-key
JWT_EXPIRES_IN=24h
JWT_REFRESH_EXPIRES_IN=168h

# Midtrans Payment Gateway (Sandbox)
MIDTRANS_SERVER_KEY=your-midtrans-server-key
MIDTRANS_CLIENT_KEY=your-midtrans-client-key
MIDTRANS_ENVIRONMENT=sandbox

# Cloudflare R2 (S3-Compatible Storage)
CLOUDFLARE_ACCOUNT_ID=your-account-id
CLOUDFLARE_ACCESS_KEY_ID=your-access-key
CLOUDFLARE_SECRET_ACCESS_KEY=your-secret-key
CLOUDFLARE_BUCKET_NAME=your-bucket-name
CLOUDFLARE_PUBLIC_URL=https://your-bucket.r2.dev
Getting API Keys:
  • Midtrans: Sign up → Dashboard → Settings → Access Keys
  • Cloudflare R2: Cloudflare Dashboard → R2 → Create Bucket → API Tokens

Frontend Configuration

cd web
cp .env.example .env
Edit web/.env:
VITE_API_BASE_URL=http://localhost:8080/api/v1
VITE_MIDTRANS_CLIENT_KEY=your-midtrans-client-key

3. Database Setup

# Start PostgreSQL container
docker compose up -d postgres

# Verify container is running
docker ps

Option B: Local PostgreSQL

# Create database
psql -U postgres
CREATE DATABASE pos_kasir;
\q

Run Migrations

# Apply all database migrations
make migrate-up

# Verify migration version
make migrate-version

Seed Database (Optional)

# Insert sample data
make seed
Default Test Users:

4. Install Backend Dependencies

# From project root
go mod download
go mod verify

5. Install Frontend Dependencies

cd web
bun install

6. Generate Code

Generate sqlc Code (Type-Safe Queries)

# From project root
make sqlc-generate
This generates Go code from SQL queries in sqlc/queries/internal/*/repository/

Generate Swagger Documentation

make swag
This:
  1. Scans Go code for Swagger annotations
  2. Generates OpenAPI spec to docs/ and web/api-docs/
  3. Generates TypeScript API client for frontend
  4. Generates RBAC types for frontend

Development Workflow

Running the Backend

Option 1: Using Docker Compose (Full Stack)

# Start all services (database + backend)
make docker-be

# View logs
docker compose -f docker-compose.backend.yml logs -f

# Stop services
make docker-be-down

Option 2: Local Development with Hot Reload

# Start backend with Air (auto-reload on changes)
air

# Or run without hot reload
go run cmd/app/main.go
Backend will be available at: http://localhost:8080 Swagger UI: http://localhost:8080/swagger/index.html

Running the Frontend

cd web
bun run dev
Frontend will be available at: http://localhost:3000

Full Stack Development

Run backend and frontend simultaneously in separate terminals: Terminal 1 (Backend):
air
Terminal 2 (Frontend):
cd web
bun run dev

Makefile Commands Reference

Database Migrations

# Check current migration version
make migrate-version

# Apply all pending migrations
make migrate-up

# Rollback all migrations
make migrate-down

# Rollback last migration only
make migrate-down-one

# Create new migration file
make migrate-create name=add_tax_column
# Creates: sqlc/migrations/<version>_add_tax_column.up.sql
#          sqlc/migrations/<version>_add_tax_column.down.sql

# Force migration version (use with caution)
make migrate-force version=14

Code Generation

# Generate type-safe Go code from SQL queries
make sqlc-generate

# Generate Swagger docs + frontend API client + RBAC types
make swag

Database Seeding

# Insert sample data (users, products, categories, etc.)
make seed

Docker

# Start backend services (PostgreSQL + API)
make docker-be

# Stop backend services
make docker-be-down

Help

# Show all available commands
make help

Project Structure

POS-kasir/
├── cmd/
│   ├── app/              # Main application entry point
│   │   └── main.go       # Server startup
│   └── seeder/           # Database seeder
│       └── main.go
├── config/               # Configuration loading
│   ├── config.go         # Environment variables
│   └── helper.go
├── internal/             # Private application code
│   ├── activitylog/      # Activity logging module
│   │   ├── handler.go    # HTTP handlers
│   │   ├── service.go    # Business logic
│   │   ├── dto.go        # Data transfer objects
│   │   └── repository/   # Generated sqlc code
│   ├── categories/       # Category management
│   ├── orders/           # Order processing
│   ├── products/         # Product management
│   ├── user/             # User & auth management
│   ├── promotions/       # Promotion engine
│   ├── report/           # Analytics & reports
│   ├── settings/         # App settings
│   ├── shift/            # Cashier shift management
│   ├── printer/          # Receipt printing
│   └── common/           # Shared code
│       ├── middleware/   # Auth, RBAC, logging
│       └── store/        # Transaction store
├── pkg/                  # Public library code
│   ├── database/         # PostgreSQL connection
│   ├── logger/           # Structured logging
│   ├── payment/          # Midtrans integration
│   ├── cloudflare-r2/    # Image storage
│   ├── utils/            # JWT, helpers
│   ├── validator/        # Input validation
│   ├── cache/            # In-memory cache
│   └── escpos/           # Thermal printer protocol
├── server/               # Server setup
│   ├── server.go         # App initialization
│   ├── routes.go         # API route definitions
│   └── health.go         # Health check endpoint
├── sqlc/                 # SQL queries & schema
│   ├── migrations/       # Database migration files
│   ├── queries/          # SQL query definitions
│   └── sqlc.yaml         # sqlc configuration
├── web/                  # Frontend application
│   ├── app/              # TanStack Start app
│   │   ├── routes/       # File-based routing
│   │   ├── components/   # React components
│   │   ├── lib/          # Utilities
│   │   └── styles/       # CSS/Tailwind
│   ├── public/           # Static assets
│   ├── api-docs/         # Generated API client
│   ├── package.json      # Dependencies
│   └── vite.config.ts    # Vite configuration
├── docs/                 # Generated Swagger docs
├── .env                  # Backend environment variables
├── .air.toml             # Air hot reload config
├── docker-compose.yml    # Docker orchestration
├── Makefile              # Task automation
└── go.mod                # Go dependencies

Adding New Features

Backend: Add a New Module

Example: Adding a “Suppliers” module

1. Create Migration

make migrate-create name=create_suppliers_table
Edit sqlc/migrations/<version>_create_suppliers_table.up.sql:
CREATE TABLE suppliers (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(255) NOT NULL,
  contact_person VARCHAR(255),
  phone VARCHAR(50),
  email VARCHAR(255),
  address TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Edit sqlc/migrations/<version>_create_suppliers_table.down.sql:
DROP TABLE IF EXISTS suppliers;
Apply migration:
make migrate-up

2. Write SQL Queries

Create sqlc/queries/suppliers.sql:
-- name: CreateSupplier :one
INSERT INTO suppliers (
  name, contact_person, phone, email, address
) VALUES (
  $1, $2, $3, $4, $5
)
RETURNING *;

-- name: GetSupplier :one
SELECT * FROM suppliers WHERE id = $1 LIMIT 1;

-- name: ListSuppliers :many
SELECT * FROM suppliers ORDER BY name;

-- name: UpdateSupplier :one
UPDATE suppliers
SET 
  name = $2,
  contact_person = $3,
  phone = $4,
  email = $5,
  address = $6
WHERE id = $1
RETURNING *;

-- name: DeleteSupplier :exec
DELETE FROM suppliers WHERE id = $1;
Generate Go code:
make sqlc-generate

3. Create Module Structure

mkdir -p internal/suppliers/repository
cd internal/suppliers
Create dto.go:
package suppliers

type CreateSupplierRequest struct {
    Name          string `json:"name" validate:"required"`
    ContactPerson string `json:"contact_person"`
    Phone         string `json:"phone"`
    Email         string `json:"email" validate:"omitempty,email"`
    Address       string `json:"address"`
}

type SupplierResponse struct {
    ID            string `json:"id"`
    Name          string `json:"name"`
    ContactPerson string `json:"contact_person"`
    Phone         string `json:"phone"`
    Email         string `json:"email"`
    Address       string `json:"address"`
    CreatedAt     string `json:"created_at"`
    UpdatedAt     string `json:"updated_at"`
}
Create service.go:
package suppliers

import (
    "context"
    "POS-kasir/internal/suppliers/repository"
    "POS-kasir/pkg/logger"
)

type ISupplierService interface {
    CreateSupplier(ctx context.Context, req CreateSupplierRequest) (*SupplierResponse, error)
    GetSupplier(ctx context.Context, id string) (*SupplierResponse, error)
    ListSuppliers(ctx context.Context) ([]*SupplierResponse, error)
}

type SupplierService struct {
    repo   repository.Querier
    logger logger.ILogger
}

func NewSupplierService(repo repository.Querier, logger logger.ILogger) ISupplierService {
    return &SupplierService{
        repo:   repo,
        logger: logger,
    }
}

func (s *SupplierService) CreateSupplier(ctx context.Context, req CreateSupplierRequest) (*SupplierResponse, error) {
    supplier, err := s.repo.CreateSupplier(ctx, repository.CreateSupplierParams{
        Name:          req.Name,
        ContactPerson: req.ContactPerson,
        Phone:         req.Phone,
        Email:         req.Email,
        Address:       req.Address,
    })
    if err != nil {
        s.logger.Errorf("Failed to create supplier: %v", err)
        return nil, err
    }

    return &SupplierResponse{
        ID:            supplier.ID.String(),
        Name:          supplier.Name,
        ContactPerson: supplier.ContactPerson,
        Phone:         supplier.Phone,
        Email:         supplier.Email,
        Address:       supplier.Address,
        CreatedAt:     supplier.CreatedAt.Format(time.RFC3339),
        UpdatedAt:     supplier.UpdatedAt.Format(time.RFC3339),
    }, nil
}
Create handler.go:
package suppliers

import (
    "github.com/gofiber/fiber/v3"
    "POS-kasir/pkg/logger"
)

type ISupplierHandler interface {
    CreateSupplierHandler(c fiber.Ctx) error
    GetSupplierHandler(c fiber.Ctx) error
    ListSuppliersHandler(c fiber.Ctx) error
}

type SupplierHandler struct {
    service ISupplierService
    logger  logger.ILogger
}

func NewSupplierHandler(service ISupplierService, logger logger.ILogger) ISupplierHandler {
    return &SupplierHandler{
        service: service,
        logger:  logger,
    }
}

// @Summary Create supplier
// @Description Create a new supplier
// @Tags suppliers
// @Accept json
// @Produce json
// @Param request body CreateSupplierRequest true "Supplier data"
// @Success 201 {object} SupplierResponse
// @Router /suppliers [post]
// @Security BearerAuth
func (h *SupplierHandler) CreateSupplierHandler(c fiber.Ctx) error {
    var req CreateSupplierRequest
    if err := c.Bind().Body(&req); err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "Invalid request body",
        })
    }

    supplier, err := h.service.CreateSupplier(c.Context(), req)
    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": "Failed to create supplier",
        })
    }

    return c.Status(fiber.StatusCreated).JSON(supplier)
}

4. Register in Container

Edit server/server.go - add to AppContainer:
type AppContainer struct {
    // ... existing handlers
    SupplierHandler suppliers.ISupplierHandler
}
Edit server/server.go - add to BuildAppContainer():
// Supplier Module
supplierRepo := suppliers_repo.New(app.DB.GetPool())
supplierService := suppliers.NewSupplierService(supplierRepo, app.Logger)
supplierHandler := suppliers.NewSupplierHandler(supplierService, app.Logger)

return &AppContainer{
    // ... existing handlers
    SupplierHandler: supplierHandler,
}

5. Add Routes

Edit server/routes.go:
api.Get("/suppliers", authMiddleware, container.SupplierHandler.ListSuppliersHandler)
api.Post("/suppliers", authMiddleware, middleware.RoleMiddleware(middleware.UserRoleManager), container.SupplierHandler.CreateSupplierHandler)
api.Get("/suppliers/:id", authMiddleware, container.SupplierHandler.GetSupplierHandler)

6. Generate Swagger Docs

make swag
This updates:
  • Backend Swagger UI
  • Frontend TypeScript API client

Frontend: Add a New Page

Example: Adding a Suppliers management page

1. Create Route File

cd web/app/routes
mkdir suppliers
touch suppliers/index.tsx
Edit suppliers/index.tsx:
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { suppliersApi } from '@/api-docs'  // Auto-generated API client

export const Route = createFileRoute('/suppliers/')({  
  component: SuppliersPage,
})

function SuppliersPage() {
  const { data: suppliers, isLoading } = useQuery({
    queryKey: ['suppliers'],
    queryFn: () => suppliersApi.listSuppliers(),
  })

  if (isLoading) return <div>Loading...</div>

  return (
    <div>
      <h1>Suppliers</h1>
      <ul>
        {suppliers?.map((supplier) => (
          <li key={supplier.id}>{supplier.name}</li>
        ))}
      </ul>
    </div>
  )
}
Edit web/app/components/Sidebar.tsx:
import { Link } from '@tanstack/react-router'

<nav>
  {/* ... existing links */}
  <Link to="/suppliers">Suppliers</Link>
</nav>

3. Test

Visit http://localhost:3000/suppliers

Testing

Backend Tests

Run All Tests

# From project root
go test ./...

# With coverage
go test -cover ./...

# Verbose output
go test -v ./...

# Specific package
go test ./internal/products/...

Writing Tests

Example: internal/products/service_test.go
package products_test

import (
    "context"
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
    "POS-kasir/internal/products"
)

func TestCreateProduct(t *testing.T) {
    // Arrange
    mockRepo := new(MockProductRepository)
    mockLogger := new(MockLogger)
    service := products.NewPrdService(mockRepo, mockLogger)
    
    ctx := context.Background()
    req := products.CreateProductRequest{
        Name:  "Test Product",
        Price: 10.00,
    }
    
    mockRepo.On("CreateProduct", ctx, mock.Anything).Return(expectedProduct, nil)
    
    // Act
    result, err := service.CreateProduct(ctx, req)
    
    // Assert
    assert.NoError(t, err)
    assert.Equal(t, "Test Product", result.Name)
    mockRepo.AssertExpectations(t)
}

Frontend Tests

Run Tests

cd web
bun test

# Watch mode
bun test --watch

# Coverage
bun test --coverage

Writing Component Tests

Example: web/app/components/ProductCard.test.tsx
import { render, screen } from '@testing-library/react'
import { ProductCard } from './ProductCard'

test('renders product name', () => {
  render(<ProductCard name="Coffee" price={5.00} />)
  expect(screen.getByText('Coffee')).toBeInTheDocument()
  expect(screen.getByText('$5.00')).toBeInTheDocument()
})

Code Style & Best Practices

Go Code Style

  • Follow Effective Go
  • Use gofmt for formatting: go fmt ./...
  • Use golangci-lint for linting
  • Write clear comments for exported functions
  • Keep functions small and focused
Example:
// GetUserByID retrieves a user by their unique identifier.
// Returns an error if the user is not found or database error occurs.
func (s *UserService) GetUserByID(ctx context.Context, id string) (*User, error) {
    // Implementation
}

TypeScript Code Style

  • Use functional components with hooks
  • Prefer const over let
  • Use TypeScript types (avoid any)
  • Name event handlers with handle prefix: handleSubmit
  • Use async/await over promises

Commit Messages

Follow Conventional Commits:
feat: add supplier management module
fix: resolve order calculation bug
docs: update contributing guide
refactor: simplify product service logic
test: add unit tests for payment service
Format: <type>: <description> Types:
  • feat - New feature
  • fix - Bug fix
  • docs - Documentation
  • style - Formatting
  • refactor - Code refactoring
  • test - Tests
  • chore - Maintenance

Debugging

Backend Debugging

Using Delve (Go Debugger)

# Install Delve
go install github.com/go-delve/delve/cmd/dlv@latest

# Start debugger
dlv debug cmd/app/main.go

# Set breakpoint
(dlv) break main.main
(dlv) continue

Using VS Code

Create .vscode/launch.json:
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Launch Server",
      "type": "go",
      "request": "launch",
      "mode": "debug",
      "program": "${workspaceFolder}/cmd/app"
    }
  ]
}

Logging

Use the logger:
s.logger.Debugf("Processing order: %s", orderID)
s.logger.Infof("Order created successfully: %s", orderID)
s.logger.Warnf("Low stock for product: %s", productID)
s.logger.Errorf("Failed to process payment: %v", err)

Frontend Debugging

Browser DevTools

  • React DevTools - Inspect component tree
  • TanStack Query DevTools - View query cache
  • Network Tab - Monitor API calls

Console Logging

console.log('User data:', user)
console.error('API error:', error)
console.table(products)  // Nice table format

Common Issues & Solutions

Migration Errors

Issue: “Dirty database version” Solution:
# Check current version
make migrate-version

# Force to last working version
make migrate-force version=14

# Re-run migrations
make migrate-up

Port Already in Use

Issue: bind: address already in use Solution:
# Find process using port 8080
lsof -i :8080

# Kill process
kill -9 <PID>

Database Connection Failed

Issue: connection refused Solutions:
  1. Ensure PostgreSQL is running: docker ps or pg_isready
  2. Check .env credentials
  3. Verify database exists: psql -U postgres -l

CORS Errors

Issue: blocked by CORS policy Solution: Update .env:
CORS_ALLOW_ORIGINS=http://localhost:3000,http://localhost:5173

sqlc Generation Fails

Issue: column "deleted_at" does not exist Solution: Run migrations first:
make migrate-up
make sqlc-generate

Deployment

Building for Production

Backend

# Build binary
go build -o bin/pos-kasir cmd/app/main.go

# Run binary
./bin/pos-kasir

Frontend

cd web
bun run build

# Output in: web/.output/

Docker Deployment

# Build and run all services
docker compose up -d

# View logs
docker compose logs -f

# Stop services
docker compose down

Getting Help

Resources

Community

  • GitHub Discussions: Ask questions and share ideas
  • Pull Requests: Contribute code improvements

Reporting Bugs

When reporting bugs, please include:
  1. Description of the issue
  2. Steps to reproduce
  3. Expected behavior
  4. Actual behavior
  5. Environment (OS, Go version, PostgreSQL version)
  6. Logs (if applicable)
Example Issue:
Title: Order discount not applying to delivery orders

Description:
When creating a delivery order with a promotion code, the discount 
is not being applied to the order total.

Steps to Reproduce:
1. Create a new order with type "delivery"
2. Add products to cart
3. Apply promotion code "DELIVERY10"
4. Proceed to checkout

Expected: 10% discount applied
Actual: No discount applied

Environment:
- OS: macOS 13.0
- Go: 1.21
- PostgreSQL: 15

Logs:
[Attach relevant logs]

License

POS Kasir is open-source software licensed under the MIT License.

Next Steps

Now that you have your development environment set up:
  1. Explore the codebase - Read through different modules
  2. Run the app - Test the features
  3. Pick an issue - Find a “good first issue” on GitHub
  4. Make changes - Implement your feature or fix
  5. Submit PR - Share your contribution
Happy coding! 🚀

See Also

Build docs developers (and LLMs) love