Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .agents/instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Agent Role
You are an expert full-stack and IoT software engineer assisting with the "UniCard" open-source project.

# Project Context
UniCard is a closed-loop contactless payment system for retail and public transportation.
**Crucial Distinction:** While this system integrates with physical hardware (ESP32 & RFID RC522 terminals), the software component you are building is a **full web application**, not just embedded logic.

# The Tech Stack
* **Backend:** Go (Golang) using standard library `net/http` where possible.
* **Frontend:** HTML, JS. Tailwind CSS.
* **Database:** MySQL.
* **Hardware Interface:** ESP32 sending payloads to the Go backend.

# Architecture & Coding Standards
1. **Backend Structure:** Enforce clean code architecture. Use modular packages and strict dependency injection.
2. **Version Control:** All work must follow Gitflow standards. Write commit messages using conventional commit naming (e.g., `feat: add transaction handler`, `fix: resolve db race condition`).
3. **Modularity:** Keep functions small. Do not create massive monolithic files.
4. **Error Handling:** Implement robust logging, especially for database transactions and hardware-to-server HTTP requests.

# Core Business Logic Rules
* Support two payment modes: Itemized Retail and Distance-based Transport (jeepneys, buses, tricycles).
* Automatically apply a 20% discount for eligible users (PWD/Students).
* Automatically calculate and apply a 0.2% cashback point reward per transaction.
* Trigger email receipts via Gmail SMTP upon successful transactions.

# Workflow Directives
* When asked to build a feature, start by scaffolding the directory structure or proposing the database schema before writing the implementation logic.
* If a bug occurs, analyze the provided terminal or browser console error logs before proposing a fix.
26 changes: 24 additions & 2 deletions .agents/rules/unicard.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
---
trigger: always_on
glob:
description:
---

# Agent Role & Context
You are an expert full-stack and IoT software engineer assisting with "UniCard," an open-source, closed-loop contactless payment system.
You are building the **software web application** side of the ecosystem (not the embedded C++ hardware code, unless explicitly asked).

# Tech Stack & Guidelines
- **Backend (Go):** Use standard library `net/http`. Enforce clean code architecture, modular packages, and strict dependency injection.
- **Frontend (Vue.js):** Use Vue 3, Vite, and Tailwind CSS. Build responsive, reusable components.
- **Database (MySQL):** Write optimized, secure queries. Handle financial transaction states strictly.
- **Hardware Integration:** The system receives payloads from ESP32/RFID RC522 terminals. Ensure secure and robust API endpoints to handle these payloads.

# Coding Standards
1. **No Monoliths:** Keep functions small and modular. Break large files down.
2. **Version Control:** Adhere strictly to Gitflow. Use conventional commit messages (e.g., `feat:`, `fix:`, `refactor:`).
3. **Error Handling:** Implement robust logging for database transactions and hardware-to-server HTTP requests. Do not swallow errors.

# Core Business Logic Directives
- **Dual Modes:** Support Retail (itemized) and Transport (distance-based) payment flows.
- **Automated Calculations:** Apply a 20% discount for eligible users (PWD/Students) and calculate a 0.2% cashback point reward per transaction.
- **Notifications:** Trigger email receipts via Gmail SMTP upon successful transactions.

# Workflow Rules
- **Analyze First:** Before proposing new code, read the relevant existing files to understand the current architecture.
- **Step-by-Step:** Do not output massive blocks of code across multiple files at once. Propose the architecture/schema first, wait for approval, then implement.
- **Debug Method:** When provided with an error log, analyze the stack trace and explain the root cause before writing the fix.
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,7 @@ storage/**/*
!storage/**/.gitkeep

tested/*
tested/**/*
tested/**/*

testdata/*
testdata/**/*
30 changes: 9 additions & 21 deletions backend/cmd/app/main.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package main

import (
"database/sql"
"fmt"
"html/template"
"log"
Expand All @@ -11,15 +10,14 @@ import (
authentication "unicard-go/backend/internal/auth"
"unicard-go/backend/internal/merchant"
"unicard-go/backend/internal/middleware"
"unicard-go/backend/internal/pkg/database"
"unicard-go/backend/internal/user"

_ "github.com/go-sql-driver/mysql"
"github.com/joho/godotenv"
)

var (
tpl *template.Template
db *sql.DB
)

func main() {
Expand All @@ -35,37 +33,27 @@ func main() {
// read .env VALUES
port := os.Getenv("PORT")
serverAddress := os.Getenv("SERVER_PORT")
dbUser := os.Getenv("DB_USER")
dbPass := os.Getenv("DB_PASSWORD")
dbName := os.Getenv("DB_NAME")
dbHost := os.Getenv("DB_HOST")
dbPort := os.Getenv("DB_PORT")
// Enable parseTime and set location so MySQL DATETIME/TIMESTAMP scan into time.Time
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true&loc=Local", dbUser, dbPass, dbHost, dbPort, dbName)

// Setup Templates
tpl, err = template.ParseGlob("./frontend/templates/*/*.html")
if err != nil {
log.Fatalf("Failed to load templates: %v. Check your folder path.", err)
}

// Setup Database
db, err = sql.Open("mysql", dsn)
// Setup Database using the new database package
db, err := database.Connect()
if err != nil {
panic(err.Error())
log.Fatalf("Failed to connect to database: %v", err)
}
defer db.Close()

// Always verify connection
if err := db.Ping(); err != nil {
panic("Database connection failed: " + err.Error())
}
store := database.NewStore(db)

// Initialize the Handler from the auth package
authHandler := authentication.NewHandler(db, tpl)
adminHanlder := admin.NewHandler(db, tpl)
userHandler := user.NewHandler(db, tpl)
merchantHandler := merchant.NewHandler(db, tpl)
authHandler := authentication.NewHandler(store, tpl)
adminHanlder := admin.NewHandler(store, tpl)
userHandler := user.NewHandler(store, tpl)
merchantHandler := merchant.NewHandler(store, tpl)

// Setup Router
mux := http.NewServeMux()
Expand Down
10 changes: 6 additions & 4 deletions backend/internal/admin/add_card.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import (
"github.com/go-playground/validator/v10"
)

const cardType = "regular" // Lowercase to match your database ENUM

// AddCardsView renders the addCards.html template after checking the admin session.
func (h *Handler) AddCardsView(w http.ResponseWriter, r *http.Request) {
fmt.Println("AddCardsView running...")
Expand Down Expand Up @@ -70,8 +72,8 @@ func (h *Handler) AddCardHandler(w http.ResponseWriter, r *http.Request) {

// Auto-generate data
cardNumber := h.generateCardNumber()
cardType := "regular" // Lowercase to match your database ENUM
expiryDate := time.Now().AddDate(2, 0, 0).Format("2006-01-02") // 2 years from now unlinked
// Set card to be unlinked for 2 years
expiryDate := time.Now().AddDate(2, 0, 0).Format("2006-01-02")

// Check for existing UID
uidExists, err := h.cardUIDExist(cardUID)
Expand All @@ -97,7 +99,7 @@ func (h *Handler) AddCardHandler(w http.ResponseWriter, r *http.Request) {
VALUES (?, ?, ?, ?, ?, 'inactive')
`

_, err = h.DB.Exec(
_, err = h.Store.Exec(
query,
cardUID,
cardNumber,
Expand Down Expand Up @@ -127,7 +129,7 @@ func (h *Handler) AddCardHandler(w http.ResponseWriter, r *http.Request) {
func (h *Handler) cardUIDExist(uid string) (bool, error) {
var existingUID string
query := "SELECT card_uid FROM cards WHERE card_uid = ?"
err := h.DB.QueryRow(query, uid).Scan(&existingUID)
err := h.Store.QueryRow(query, uid).Scan(&existingUID)
if err == sql.ErrNoRows {
return false, nil
} else if err != nil {
Expand Down
17 changes: 10 additions & 7 deletions backend/internal/admin/add_merchant.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (

"github.com/go-playground/validator/v10"
"golang.org/x/crypto/bcrypt"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)

// AddMerchantRequest represents the payload for adding a new merchant
Expand Down Expand Up @@ -52,7 +54,7 @@ func (h *Handler) AddMerchantHandler(w http.ResponseWriter, r *http.Request) {
return
}

tx, err := h.DB.Begin()
tx, err := h.Store.Begin()
if err != nil {
log.Printf("Error starting tx: %v", err)
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
Expand Down Expand Up @@ -105,18 +107,19 @@ func (h *Handler) AddMerchantHandler(w http.ResponseWriter, r *http.Request) {
defer termStmt.Close()

for i, req := range reqs {
caser := cases.Title(language.English)
// Clean and format string fields
req.BusinessName = strings.Title(strings.ToLower(strings.TrimSpace(req.BusinessName)))
req.BusinessAddress = strings.Title(strings.ToLower(strings.TrimSpace(req.BusinessAddress)))
req.OwnerName = strings.Title(strings.ToLower(strings.TrimSpace(req.OwnerName)))
req.SettlementName = strings.Title(strings.ToLower(strings.TrimSpace(req.SettlementName)))
req.BusinessName = caser.String(strings.ToLower(strings.TrimSpace(req.BusinessName)))
req.BusinessAddress = caser.String(strings.ToLower(strings.TrimSpace(req.BusinessAddress)))
req.OwnerName = caser.String(strings.ToLower(strings.TrimSpace(req.OwnerName)))
req.SettlementName = caser.String(strings.ToLower(strings.TrimSpace(req.SettlementName)))

// Some fields don't need title case but should be trimmed
req.BusinessEmail = strings.ToLower(strings.TrimSpace(req.BusinessEmail))
req.BusinessPhone = strings.TrimSpace(req.BusinessPhone)
req.TerminalSN = strings.TrimSpace(req.TerminalSN)
req.DeviceName = strings.TrimSpace(req.DeviceName)

reqs[i] = req // update back to slice

err := Validate.Struct(req)
Expand Down
20 changes: 11 additions & 9 deletions backend/internal/admin/admin_dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"net/http"
jsonwrite "unicard-go/backend/internal/pkg/handler"
structs "unicard-go/backend/internal/pkg/structs"

"github.com/shopspring/decimal"
)

type AdminPageData struct {
Expand All @@ -30,9 +32,9 @@ func (h *Handler) AdminDashboardDataHandler(w http.ResponseWriter, r *http.Reque
// Compute UniCard's Absolute Gross Revenue
// (Transaction Service Fees)
query := `SELECT COALESCE(SUM(service_fee), 0.00) FROM transactions WHERE transaction_type IN ('payment', 'topup', 'withdrawal')`
row := h.DB.QueryRow(query)
row := h.Store.QueryRow(query)

var grossRevenue float64
var grossRevenue decimal.Decimal
if err := row.Scan(&grossRevenue); err != nil {
log.Println("Error scanning gross revenue:", err)
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
Expand All @@ -53,9 +55,9 @@ func (h *Handler) AdminDashboardDataHandler(w http.ResponseWriter, r *http.Reque
END
), 0.00) FROM transactions`

row = h.DB.QueryRow(query)
row = h.Store.QueryRow(query)

var netRevenue float64
var netRevenue decimal.Decimal
if err := row.Scan(&netRevenue); err != nil {
log.Println("Error scanning net revenue:", err)
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
Expand All @@ -68,7 +70,7 @@ func (h *Handler) AdminDashboardDataHandler(w http.ResponseWriter, r *http.Reque

// Display the number of users
// Counting only 'active' customers (excluding suspended or inactive accounts)
row = h.DB.QueryRow("SELECT COUNT(*) FROM users WHERE role = 'customer'") // status = 'active'
row = h.Store.QueryRow("SELECT COUNT(*) FROM users WHERE role = 'customer'") // status = 'active'

var totalUsers int
if err := row.Scan(&totalUsers); err != nil {
Expand All @@ -83,7 +85,7 @@ func (h *Handler) AdminDashboardDataHandler(w http.ResponseWriter, r *http.Reque

// Display the number of cards
// Counting only 'active' cards to show actual circulatory supply
row = h.DB.QueryRow("SELECT COUNT(*) FROM cards") // WHERE status = 'active'
row = h.Store.QueryRow("SELECT COUNT(*) FROM cards") // WHERE status = 'active'

var totalCards int
if err := row.Scan(&totalCards); err != nil {
Expand All @@ -97,7 +99,7 @@ func (h *Handler) AdminDashboardDataHandler(w http.ResponseWriter, r *http.Reque
log.Println("Total cards row:", totalCards)

// Display the number of merchants and breakdown
row = h.DB.QueryRow(`
row = h.Store.QueryRow(`
SELECT
COUNT(*),
COALESCE(SUM(CASE WHEN status = 'pending_approval' THEN 1 ELSE 0 END), 0),
Expand All @@ -118,7 +120,7 @@ func (h *Handler) AdminDashboardDataHandler(w http.ResponseWriter, r *http.Reque
log.Println("Total merchants:", totalMerchants, "Pending:", pendingMerchants)

// Display the number of terminals and breakdown
row = h.DB.QueryRow(`
row = h.Store.QueryRow(`
SELECT
COUNT(*),
COALESCE(SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END), 0),
Expand All @@ -139,7 +141,7 @@ func (h *Handler) AdminDashboardDataHandler(w http.ResponseWriter, r *http.Reque

// Fetch recent merchants for the table (limit 5)
merchantQuery := "SELECT merchant_id, business_name, business_type, owner_name, business_email, business_phone, status, created_at FROM merchants ORDER BY created_at DESC LIMIT 5"
rows, err := h.DB.Query(merchantQuery)
rows, err := h.Store.Query(merchantQuery)
if err != nil {
log.Println("Error querying merchants:", err)
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
Expand Down
18 changes: 9 additions & 9 deletions backend/internal/admin/admin_merchant.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func (h *Handler) MerchantManagementDataHandler(w http.ResponseWriter, r *http.R
// Count total items
countQuery := `SELECT COUNT(*) ` + baseQuery + whereClause
var totalItems int
if err := h.DB.QueryRow(countQuery, args...).Scan(&totalItems); err != nil {
if err := h.Store.QueryRow(countQuery, args...).Scan(&totalItems); err != nil {
log.Println("Error counting merchants:", err)
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
Success: false,
Expand All @@ -101,7 +101,7 @@ func (h *Handler) MerchantManagementDataHandler(w http.ResponseWriter, r *http.R

args = append(args, limit, offset)

rows, err := h.DB.Query(query, args...)
rows, err := h.Store.Query(query, args...)
if err != nil {
log.Println("Error querying merchants:", err)
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
Expand Down Expand Up @@ -143,7 +143,7 @@ func (h *Handler) MerchantManagementDataHandler(w http.ResponseWriter, r *http.R
JOIN merchants m ON t.merchant_id = m.merchant_id
WHERE m.merchant_id IN (%s)`, strings.Join(placeholders, ","))

termRows, err := h.DB.Query(termQuery, termArgs...)
termRows, err := h.Store.Query(termQuery, termArgs...)
if err == nil {
defer termRows.Close()
termMap := make(map[string][]structs.Terminal)
Expand Down Expand Up @@ -211,7 +211,7 @@ func (h *Handler) ApproveMerchantHandler(w http.ResponseWriter, r *http.Request)
}

// Begin TX
tx, err := h.DB.Begin()
tx, err := h.Store.Begin()
if err != nil {
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Database error"})
return
Expand Down Expand Up @@ -386,7 +386,7 @@ func (h *Handler) RejectMerchantHandler(w http.ResponseWriter, r *http.Request)
return
}

tx, err := h.DB.Begin()
tx, err := h.Store.Begin()
if err != nil {
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Database error"})
return
Expand Down Expand Up @@ -467,7 +467,7 @@ func (h *Handler) SuspendMerchantHandler(w http.ResponseWriter, r *http.Request)
return
}

tx, err := h.DB.Begin()
tx, err := h.Store.Begin()
if err != nil {
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Database error"})
return
Expand Down Expand Up @@ -536,7 +536,7 @@ func (h *Handler) DeleteMerchantHandler(w http.ResponseWriter, r *http.Request)
return
}

tx, err := h.DB.Begin()
tx, err := h.Store.Begin()
if err != nil {
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Database error"})
return
Expand Down Expand Up @@ -672,7 +672,7 @@ func (h *Handler) MerchantInfoDataHandler(w http.ResponseWriter, r *http.Request
// ADDED: Nullable types for address, phone, and type to prevent NULL crashes
var setBank, setName, setAcct, regNum, dtiDoc, birDoc, otherDoc, city, postal, docStatus, busAddress, busPhone, busType sql.NullString

err := h.DB.QueryRow(`
err := h.Store.QueryRow(`
SELECT merchant_id, user_id, business_name, business_type, business_registration_number,
business_address, city, postal_code, owner_name, business_email, business_phone, status,
commission_rate, settlement_bank_name, settlement_account_name,
Expand Down Expand Up @@ -751,7 +751,7 @@ func (h *Handler) MerchantInfoDataHandler(w http.ResponseWriter, r *http.Request
WHERE merchant_id = ?
`

termRows, err := h.DB.Query(termQuery, merchantID)
termRows, err := h.Store.Query(termQuery, merchantID)
if err != nil {
log.Println("Error querying merchant terminals:", err)
m.Terminals = []structs.Terminal{}
Expand Down
Loading