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
30 changes: 29 additions & 1 deletion backend/cmd/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ func main() {
mux.Handle("GET /u/{username}/dashboard", requireCustomer(http.HandlerFunc(userHandler.DashboardView)))
mux.Handle("GET /u/{username}/card", requireCustomer(http.HandlerFunc(userHandler.CardView)))
mux.Handle("POST /v1/user/{username}/card/status", requireCustomer(http.HandlerFunc(userHandler.UpdateCardStatus)))
mux.Handle("POST /v1/user/{username}/card/replace", requireCustomer(http.HandlerFunc(userHandler.RequestReplacement)))
mux.Handle("GET /u/{username}/settings", requireCustomer(http.HandlerFunc(userHandler.SettingsView)))
mux.Handle("GET /u/{username}/topup", requireCustomer(http.HandlerFunc(userHandler.TopUpView)))
// Your frontend calls this to get the Xendit URL
Expand Down Expand Up @@ -176,9 +177,36 @@ func main() {
mux.ServeHTTP(w, r)
})

// Wrap with CORS middleware
handler := corsMiddleware(customHandler)

// Start Server
fmt.Println("Server started on: http://" + serverAddress + ":" + port)
if err := http.ListenAndServe(serverAddress+":"+port, customHandler); err != nil {
if err := http.ListenAndServe(serverAddress+":"+port, handler); err != nil {
log.Fatal(err)
}
}
func corsMiddleware(next http.Handler) http.Handler {
allowedOrigins := map[string]bool{
os.Getenv("CORS_ALLOWED_ORIGINS"): true, // Load from .env
//"http://localhost:5173": true, // Vue dev
//"http://localhost:3001": true, // Go dev
//"https://unicard.app": true, // production
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if allowedOrigins[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Credentials", "true")

if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}

next.ServeHTTP(w, r)
})
}
86 changes: 85 additions & 1 deletion backend/internal/user/customer_card.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"net/http"
"time"
jsonwrite "unicard-go/backend/internal/pkg/handler"
)

func (h *Handler) CardView(w http.ResponseWriter, r *http.Request) {
Expand All @@ -21,7 +23,7 @@ func (h *Handler) CardView(w http.ResponseWriter, r *http.Request) {

func (h *Handler) UpdateCardStatus(w http.ResponseWriter, r *http.Request) {
username := r.PathValue("username")

var req struct {
Status string `json:"status"`
}
Expand Down Expand Up @@ -52,3 +54,85 @@ func (h *Handler) UpdateCardStatus(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"success": true}`))
}

func (h *Handler) RequestReplacement(w http.ResponseWriter, r *http.Request) {
username := r.PathValue("username")

var userID string
err := h.Store.QueryRow("SELECT user_id FROM users WHERE username = ?", username).Scan(&userID)
if err != nil {
jsonwrite.WriteJSON(w, http.StatusNotFound, jsonwrite.APIResponse{
Success: false,
Message: "User not found",
})
return
}

var balance float64
var cardNumber string
err = h.Store.QueryRow("SELECT balance, card_number FROM cards WHERE user_id = ?", userID).Scan(&balance, &cardNumber)
if err != nil {
jsonwrite.WriteJSON(w, http.StatusNotFound, jsonwrite.APIResponse{
Success: false,
Message: "Card not found",
})
return
}

if balance < 150.0 {
jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{
Success: false,
Message: "Insufficient balance. Replacement fee is 150 PHP.",
})
return
}

// Deduct balance and set card to locked (represented as 'blocked')
// Using ExecTx if available, or just manual Begin
tx, err := h.Store.Begin()
if err != nil {
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
Success: false,
Message: "Database error",
})
return
}
defer tx.Rollback()

_, err = tx.Exec("UPDATE cards SET balance = balance - 150.0, status = 'blocked' WHERE user_id = ?", userID)
if err != nil {
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
Success: false,
Message: "Failed to update card",
})
return
}

// Insert transaction record for the fee
txnID := fmt.Sprintf("REP-%s-%d", cardNumber, time.Now().Unix())
_, err = tx.Exec(`
INSERT INTO transactions (transaction_id, card_number, transaction_type, amount, status, description)
VALUES (?, ?, 'payment', 150.0, 'completed', 'Card Replacement Fee')
`, txnID, cardNumber)

if err != nil {
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
Success: false,
Message: "Failed to record transaction",
})
return
}

if err := tx.Commit(); err != nil {
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
Success: false,
Message: "Failed to commit transaction",
})
return
}

jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{
Success: true,
Message: "Card replacement requested successfully. Card is now locked.",
})
}
8 changes: 6 additions & 2 deletions backend/internal/user/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type DashboardUser struct {
CardNumber string `json:"card_number"`
CardExpiry string `json:"card_expiry"`
CardStatus string `json:"card_status"`
UserStatus string `json:"user_status"`
RecentTransactions []Transaction `json:"recent_transactions"` // Add recent transactions to the dashboard response
}

Expand Down Expand Up @@ -99,13 +100,15 @@ func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) {
COALESCE(c.loyalty_points, 0),
COALESCE(c.card_number, ''),
COALESCE(c.expiry_date, ''),
COALESCE(c.status, '')
COALESCE(c.status, ''),
COALESCE(u.status, '')
FROM users u
LEFT JOIN cards c
ON u.user_id = c.user_id
WHERE u.username = ?
`
err := h.Store.QueryRow(stmt, userID).Scan(&id, &username, &fullName, &email, &pendingEmail, &phone, &userType, &balance, &loyaltyPoints, &cardNumber, &expiryDate, &cardStatus)
var userStatus string
err := h.Store.QueryRow(stmt, userID).Scan(&id, &username, &fullName, &email, &pendingEmail, &phone, &userType, &balance, &loyaltyPoints, &cardNumber, &expiryDate, &cardStatus, &userStatus)
if err != nil {
if err == sql.ErrNoRows {
fmt.Printf("User %s not found in DB\n", userID)
Expand Down Expand Up @@ -223,6 +226,7 @@ func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) {
CardNumber: cardNumber,
CardExpiry: expiryStr,
CardStatus: cardStatus,
UserStatus: userStatus,
RecentTransactions: transactions,
}

Expand Down
11 changes: 8 additions & 3 deletions backend/internal/user/transaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ func (h *Handler) TransactionsJSONHandler(w http.ResponseWriter, r *http.Request
SELECT
t.transaction_id,
COALESCE(t.terminal_id, ''),
DATE(t.created_at) as date,
TIME(t.created_at) as time,
t.created_at,
COALESCE(t.transaction_type, ''),
t.amount,
COALESCE(t.status, ''),
Expand Down Expand Up @@ -86,11 +85,17 @@ func (h *Handler) TransactionsJSONHandler(w http.ResponseWriter, r *http.Request
var pointsEarned decimal.Decimal
var cardNumber string

err := rows.Scan(&t.TransactionID, &t.TerminalID, &t.Date, &t.Time, &t.Type, &t.Amount, &t.Status, &description, &businessName, &merchantId, &pointsEarned, &cardNumber)
var createdAt string

err := rows.Scan(&t.TransactionID, &t.TerminalID, &createdAt, &t.Type, &t.Amount, &t.Status, &description, &businessName, &merchantId, &pointsEarned, &cardNumber)
if err != nil {
fmt.Printf("Error scanning transaction row: %v\n", err)
continue
}

t.Date = formatDate(createdAt)
t.Time = formatTime(createdAt)

t.Description = description

if businessName != "" {
Expand Down
25 changes: 22 additions & 3 deletions frontend/assets/js/customer_card.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,26 @@ document.addEventListener("DOMContentLoaded", function () {
});
}

function requestReplacementAPI(onSuccess) {
if (!userId) return;
fetch(`/v1/user/${encodeURIComponent(userId)}/card/replace`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
.then(res => res.json().then(data => ({ status: res.status, ok: res.ok, data })))
.then(res => {
if (res.ok && res.data.success) {
onSuccess();
} else {
alert(res.data.message || "Failed to request replacement. Please try again.");
}
})
.catch(err => {
console.error(err);
alert("Error requesting replacement.");
});
}

// --- Card Toggle & Copy Logic ---
const toggleCardBtn = document.getElementById("toggle-card-btn");
const copyCardBtn = document.getElementById("copy-card-btn");
Expand Down Expand Up @@ -242,8 +262,7 @@ document.addEventListener("DOMContentLoaded", function () {

if (confirmReplacementButton) {
confirmReplacementButton.addEventListener('click', () => {
// "Request Replacement will turn the card into status 'lost'"
updateCardStatusAPI('lost', () => {
requestReplacementAPI(() => {
reportButton.disabled = true;
reportButton.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /></svg> Card Blocked';
reportButton.classList.add('opacity-50', 'cursor-not-allowed');
Expand All @@ -252,7 +271,7 @@ document.addEventListener("DOMContentLoaded", function () {
replacementButton.textContent = 'Replacement Requested';
replacementButton.classList.add('opacity-50', 'cursor-not-allowed');

setCardStatus("Lost");
setCardStatus("Blocked");
closeModal(replacementModal, replacementModalContent);
});
});
Expand Down
12 changes: 12 additions & 0 deletions frontend/assets/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,15 @@ document.addEventListener("DOMContentLoaded", function () {
const amount = Number(tx.amount).toFixed(2);
const displayType = tx.type ? tx.type.charAt(0).toUpperCase() + tx.type.slice(1) : "";

let statusHtml = "";
if (tx.status) {
const statusVal = tx.status.toLowerCase();
const statusColor = statusVal === "completed" ? "bg-green-100 text-green-800" :
statusVal === "pending" ? "bg-yellow-100 text-yellow-800" :
"bg-red-100 text-red-800";
statusHtml = `<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full capitalize ${statusColor}">${tx.status}</span>`;
}

tr.className = "hover:bg-slate-50 transition-colors cursor-pointer border-b border-slate-100";
tr.onclick = function () {
openTxnModal(tx);
Expand All @@ -298,6 +307,9 @@ document.addEventListener("DOMContentLoaded", function () {
<td class="px-6 py-4 whitespace-nowrap text-sm ${colorClass} text-right font-medium">
${sign}₱${amount}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
${statusHtml}
</td>
`;
transactionsBody.appendChild(tr);
});
Expand Down
20 changes: 16 additions & 4 deletions frontend/templates/customer/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -138,23 +138,26 @@ <h2 class="text-xl font-bold text-gray-900">Recent Transactions</h2>
<div class="overflow-x-auto border border-gray-100 rounded-xl">
<table class="min-w-full table-fixed divide-y divide-gray-100">
<thead class="bg-gray-50">
<tr>
<th scope="col"
class="w-[20%] px-6 py-3.5 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">
Date
</th>
<th scope="col"
class="w-[40%] px-6 py-3.5 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">
class="w-[30%] px-6 py-3.5 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">
Description
</th>
<th scope="col"
class="w-[20%] px-6 py-3.5 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">
class="w-[15%] px-6 py-3.5 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">
Type
</th>
<th scope="col"
class="w-[20%] px-6 py-3.5 text-right text-xs font-bold text-gray-500 uppercase tracking-wider">
class="w-[15%] px-6 py-3.5 text-right text-xs font-bold text-gray-500 uppercase tracking-wider">
Amount
</th>
<th scope="col"
class="w-[20%] px-6 py-3.5 text-right text-xs font-bold text-gray-500 uppercase tracking-wider">
Status
</th>
</tr>
</thead>
<tbody id="recent-transactions-table-body"
Expand All @@ -173,6 +176,9 @@ <h2 class="text-xl font-bold text-gray-900">Recent Transactions</h2>
<td class="px-6 py-4 whitespace-nowrap flex justify-end">
<div class="h-4 bg-gray-200 rounded animate-pulse w-20"></div>
</td>
<td class="px-6 py-4 whitespace-nowrap flex justify-end">
<div class="h-4 bg-gray-200 rounded animate-pulse w-16"></div>
</td>
</tr>
<!-- Skeleton Row 2 -->
<tr>
Expand All @@ -188,6 +194,9 @@ <h2 class="text-xl font-bold text-gray-900">Recent Transactions</h2>
<td class="px-6 py-4 whitespace-nowrap flex justify-end">
<div class="h-4 bg-gray-200 rounded animate-pulse w-20"></div>
</td>
<td class="px-6 py-4 whitespace-nowrap flex justify-end">
<div class="h-4 bg-gray-200 rounded animate-pulse w-16"></div>
</td>
</tr>
<!-- Skeleton Row 3 -->
<tr>
Expand All @@ -203,6 +212,9 @@ <h2 class="text-xl font-bold text-gray-900">Recent Transactions</h2>
<td class="px-6 py-4 whitespace-nowrap flex justify-end">
<div class="h-4 bg-gray-200 rounded animate-pulse w-20"></div>
</td>
<td class="px-6 py-4 whitespace-nowrap flex justify-end">
<div class="h-4 bg-gray-200 rounded animate-pulse w-16"></div>
</td>
</tr>
</tbody>
</table>
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/shopspring/decimal v1.4.0
github.com/xendit/xendit-go/v7 v7.0.0
golang.org/x/crypto v0.53.0
golang.org/x/text v0.38.0
golang.org/x/time v0.15.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
)
Expand All @@ -24,6 +25,5 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
)