From d6c67882bb0e62ff0c5d7fddb774eec9cfa4e897 Mon Sep 17 00:00:00 2001 From: devzeeh <148837352+devzeeh@users.noreply.github.com> Date: Sat, 13 Jun 2026 15:07:58 +0800 Subject: [PATCH 1/4] feat: remove Stripe dependency from go.mod and go.sum --- .gitignore | 4 ++++ go.mod | 1 - go.sum | 2 -- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index dc928a4..afe833e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,10 @@ .env .unknown +# gitgore the compiled file +*.exe + +# gitgore the storage directory storage/* !storage/.gitkeep storage/**/* diff --git a/go.mod b/go.mod index a3a6a8d..cf0665c 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ require ( github.com/go-playground/validator/v10 v10.30.2 github.com/go-sql-driver/mysql v1.9.3 github.com/joho/godotenv v1.5.1 - github.com/stripe/stripe-go/v85 v85.2.0 github.com/xendit/xendit-go v1.0.25 golang.org/x/crypto v0.49.0 golang.org/x/time v0.15.0 diff --git a/go.sum b/go.sum index 20dee08..9ebca94 100644 --- a/go.sum +++ b/go.sum @@ -38,8 +38,6 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/stripe/stripe-go/v85 v85.2.0 h1:LomL8ulv13+y+nIKtFP48Cn/pCIZmi4IBz5qjdDn+FY= -github.com/stripe/stripe-go/v85 v85.2.0/go.mod h1:5P+HGFenpWgak27T5Is6JMsmDfUC1yJnjhhmquz7kXw= github.com/xendit/xendit-go v1.0.25 h1:o93nh+imxUwEgezPXzz9A1pMEXBHcx7V9rUICZJXmNY= github.com/xendit/xendit-go v1.0.25/go.mod h1:JPte2sEsATw1iUHkBiZpcRuySn0CmcomaeHjfDlwpYo= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= From 34dd0c1d5a64633ea7764524182d7d3ab256a604 Mon Sep 17 00:00:00 2001 From: devzeeh <148837352+devzeeh@users.noreply.github.com> Date: Sat, 13 Jun 2026 15:08:50 +0800 Subject: [PATCH 2/4] feat: add Xendit payment integration and create sample for wallet top-up functionality --- backend/cmd/app/config.yml | 1 + docs/unicardv3.sql | 5 ++--- xendit_sample.go | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 backend/cmd/app/config.yml create mode 100644 xendit_sample.go diff --git a/backend/cmd/app/config.yml b/backend/cmd/app/config.yml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/backend/cmd/app/config.yml @@ -0,0 +1 @@ +{} diff --git a/docs/unicardv3.sql b/docs/unicardv3.sql index 1b82cc6..9bbdb81 100644 --- a/docs/unicardv3.sql +++ b/docs/unicardv3.sql @@ -129,11 +129,10 @@ CREATE TABLE top_ups ( gateway_cost DECIMAL(10, 2) DEFAULT 0.00 COMMENT 'Actual fee incurred from external payment providers (GCash, Maya, Bank) per transaction', net_gateway_fee DECIMAL(10, 2) GENERATED ALWAYS AS (convenience_fee - gateway_cost) STORED COMMENT 'Automatically calculated net revenue kept by the platform after 3rd party costs', total_charged DECIMAL(10, 2) GENERATED ALWAYS AS (amount + convenience_fee) STORED COMMENT 'Automatically calculated column representing the absolute total cash value collected from the external channel source', - payment_method ENUM('cash', 'gcash', 'maya', 'over_the_counter', 'stripe') NOT NULL COMMENT 'Drives system tracking to audit cash-drawer liquid positions against programmatic API callbacks', + payment_method ENUM('cash', 'gcash', 'maya', 'over_the_counter', 'xendit') NOT NULL COMMENT 'Drives system tracking to audit cash-drawer liquid positions against programmatic API callbacks', handled_by VARCHAR(50) NULL COMMENT 'Public user_id string identifier referencing the administrative staff member who manually accepted physical bills if OTC cash-loaded', status ENUM('pending', 'completed', 'failed') DEFAULT 'completed' COMMENT 'State pipeline tracker handling payment gateway processing exceptions or drops', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'Auto-generated clock timestamp mapping exactly when wallet balance credits were finalized', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Tracks chronological life cycle changes, such as a top-up shifting from pending to completed', - FOREIGN KEY (card_number) REFERENCES cards(card_number), - FOREIGN KEY (handled_by) REFERENCES users(user_id) + FOREIGN KEY (card_number) REFERENCES cards(card_number) ) COMMENT='High-growth balance loader ledger maintaining immutable compliance auditing for all incoming ecosystem liquidity channels'; \ No newline at end of file diff --git a/xendit_sample.go b/xendit_sample.go new file mode 100644 index 0000000..02c99bf --- /dev/null +++ b/xendit_sample.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + "log" + + "github.com/xendit/xendit-go" + "github.com/xendit/xendit-go/invoice" +) + +func main() { + // Initialize the SDK with your Secret API Key (Keep this secure!) + xendit.Opt.SecretKey = "XENDIT_SECRET_KEY" + + // Define the parameters for the user's wallet top-up + data := invoice.CreateParams{ + ExternalID: "invoice-12345", // Unique identifier for this transaction + Amount: 10.00, // Set your required amount + Description: "Wallet Top-Up for Unicard", // Description for the transaction + PayerEmail: "unicard@dev.zeeh", + Currency: "PHP", // Adjust currency to your local demographic + } + + // Generate the invoice via Xendit API + resp, err := invoice.Create(&data) + if err != nil { + log.Fatalf("Failed to create Xendit invoice: %v", err) + } + + // Output the secure checkout URL for the user + fmt.Printf("Invoice created successfully!\n") + fmt.Printf("Direct the user to this URL to complete their top-up: %s\n", resp.InvoiceURL) +} From bcadfe0ea8912d3645d0735f36d86a36c818fb77 Mon Sep 17 00:00:00 2001 From: devzeeh <148837352+devzeeh@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:51:07 +0800 Subject: [PATCH 3/4] feat: implement user profile editing and password change functionality --- backend/cmd/app/main.go | 2 + backend/internal/user/customer_profile.go | 180 ++++++++++++++++++++++ frontend/assets/js/profile.js | 114 ++++++++++---- frontend/templates/customer/profile.html | 8 +- 4 files changed, 270 insertions(+), 34 deletions(-) diff --git a/backend/cmd/app/main.go b/backend/cmd/app/main.go index 0570e1b..a146d20 100644 --- a/backend/cmd/app/main.go +++ b/backend/cmd/app/main.go @@ -88,6 +88,8 @@ func main() { mux.HandleFunc("POST /v1/forgot-password/verify-otp", authHandler.ForgotPasswordVerifyOTP) mux.HandleFunc("POST /v1/reset-password", authHandler.ResetPassword) mux.HandleFunc("GET /u/{username}", userHandler.ProfileView) + mux.HandleFunc("PATCH /u/{username}/profile/edit", userHandler.ProfileEdit) + mux.HandleFunc("PUT /u/{username}/profile/password", userHandler.ProfileChangePassword) mux.HandleFunc("GET /u/{username}/dashboard", userHandler.DashboardView) mux.HandleFunc("GET /u/{username}/card", userHandler.CardView) mux.HandleFunc("GET /u/{username}/topup", userHandler.TopUpView) diff --git a/backend/internal/user/customer_profile.go b/backend/internal/user/customer_profile.go index d45d8f1..cd80d43 100644 --- a/backend/internal/user/customer_profile.go +++ b/backend/internal/user/customer_profile.go @@ -1,13 +1,34 @@ package user import ( + "encoding/json" "fmt" + "log" "net/http" + "strings" + "unicard-go/backend/internal/pkg/account" + jsonwrite "unicard-go/backend/internal/pkg/handler" + + "golang.org/x/crypto/bcrypt" ) +// ProfileUpdateRequest represents the expected payload for updating user profile information +type ProfileUpdateRequest struct { + Username string `json:"username,omitempty" db:"username"` + FullName string `json:"full_name,omitempty" db:"name"` + Email string `json:"email,omitempty" db:"email"` + Phone string `json:"phone_number,omitempty" db:"phone_number"` + CurrentPassword string `json:"current_password,omitempty"` + NewPassword string `json:"new_password,omitempty"` + ConfirmPassword string `json:"confirm_password,omitempty"` +} + +// ProfileView handles the display of the user's profile page +// It retrieves the username from the URL and renders the profile template func (h *Handler) ProfileView(w http.ResponseWriter, r *http.Request) { fmt.Println("Profile view is running...") + // Extract the username from the URL path username := r.PathValue("username") data := struct { Username string @@ -15,5 +36,164 @@ func (h *Handler) ProfileView(w http.ResponseWriter, r *http.Request) { Username: username, } + // Render the profile template with the username data h.Tpl.ExecuteTemplate(w, "profile.html", data) } + +// ProfileEdit renders the profile edit page (assumes router already filtered by method) +func (h *Handler) ProfileEdit(w http.ResponseWriter, r *http.Request) { + username := r.PathValue("username") + ctx := r.Context() + + var req ProfileUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, + Message: "Invalid request payload", + }) + return + } + + // PATCH — at least one field required + if req.FullName == "" && req.Email == "" && req.Phone == "" { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, + Message: "At least one field is required", + }) + return + } + + // Build query dynamically based on what was sent + fields := []string{} + args := []any{} + + if req.FullName != "" { + fields = append(fields, "name = ?") + args = append(args, req.FullName) + } + if req.Email != "" { + fields = append(fields, "email = ?") + args = append(args, req.Email) + } + if req.Phone != "" { + fields = append(fields, "phone_number = ?") + args = append(args, req.Phone) + } + + // Append username as last arg for WHERE clause + args = append(args, username) + + query := "UPDATE users SET " + strings.Join(fields, ", ") + " WHERE username = ?" + + _, err := h.DB.ExecContext(ctx, query, args...) + if err != nil { + log.Printf("ProfileEdit DB error: %v | query: %s | args: %v", err, query, args) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Failed to update profile", + }) + return + } + + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ + Success: true, + Message: "Profile updated successfully", + }) +} + +// current password verification is not implemented yet, so this endpoint just updates the password without checking the old one +// password hashing is also not implemented yet, so the password is stored in plaintext (this will be fixed in the future). (FIXED: password hashing is now implemented using bcrypt in the account package) +// lowercase the password field names in the struct to avoid accidentally exposing them in JSON responses. FIXED in the struct definition above. +func (h *Handler) ProfileChangePassword(w http.ResponseWriter, r *http.Request) { + username := r.PathValue("username") + + var req ProfileUpdateRequest + var ctx = r.Context() + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, + Message: "Invalid request payload", + }) + return + } + + if req.CurrentPassword == "" || req.NewPassword == "" || req.ConfirmPassword == "" { + log.Printf("One or more password fields are empty for user: %s", username) + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, + Message: "All password fields are required", + }) + return + } + + if req.NewPassword != req.ConfirmPassword { + log.Printf("New password and confirm password do not match for user: %s", username) + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, + Message: "Password do not match", + }) + return + } + + // Fetch current password hash + log.Printf("Verifying current password for user: %s", username) + var currentHash string + err := h.DB.QueryRowContext(ctx, "SELECT password_hash FROM users WHERE username = ?", username).Scan(¤tHash) + if err != nil { + log.Printf("Error fetching current password hash: %v", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Failed to verify current password", + }) + return + } + + // Verify current password is correct + if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(req.CurrentPassword)); err != nil { + log.Printf("Current password verification failed for user: %s", username) + jsonwrite.WriteJSON(w, http.StatusUnauthorized, jsonwrite.APIResponse{ + Success: false, + Message: "Current password is incorrect", + }) + return + } + log.Printf("Current password verified for user: %s", username) + + // Prevent setting the same password again + if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(req.NewPassword)); err == nil { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, + Message: "New password must be different from current password", + }) + return + } + + // Hash the new password using the account package's HashPassword function + hashedPassword, err := account.HashPassword(req.NewPassword) + if err != nil { + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Failed to hash password", + }) + return + } + + // Here you would hash the password and update it in the database + // Save new password hash + query := "UPDATE users SET password_hash = ? WHERE username = ?" + _, err = h.DB.ExecContext(ctx, query, hashedPassword, username) + if err != nil { + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Failed to update password", + }) + return + } + + // Respond with success message + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ + Success: true, + Message: "Password updated successfully", + }) +} diff --git a/frontend/assets/js/profile.js b/frontend/assets/js/profile.js index 4273f55..0e21b57 100644 --- a/frontend/assets/js/profile.js +++ b/frontend/assets/js/profile.js @@ -1,6 +1,8 @@ document.addEventListener("DOMContentLoaded", function () { console.log("Profile page script loaded."); + const username = document.body.dataset.username; + // --- Profile Edit Elements --- const editProfileBtn = document.getElementById('edit-profile-btn'); const cancelEditBtn = document.getElementById('cancel-edit-btn'); @@ -11,6 +13,11 @@ document.addEventListener("DOMContentLoaded", function () { if (editProfileBtn && cancelEditBtn && profileActions && profileView && profileEditForm && saveProfileBtn) { editProfileBtn.addEventListener('click', () => { + // Pre-fill edit form with current values + document.getElementById('full_name').value = document.getElementById('profile-view-name').innerText.trim(); + document.getElementById('email').value = document.getElementById('profile-view-email').innerText.trim(); + document.getElementById('phone').value = document.getElementById('profile-view-phone').innerText.trim(); + profileView.classList.add('hidden'); profileEditForm.classList.remove('hidden'); editProfileBtn.classList.add('hidden'); @@ -25,27 +32,48 @@ document.addEventListener("DOMContentLoaded", function () { editProfileBtn.classList.remove('hidden'); }); - saveProfileBtn.addEventListener('click', (e) => { + saveProfileBtn.addEventListener('click', async (e) => { e.preventDefault(); - - // In a real app, send fetch() request here + const newName = document.getElementById('full_name').value; const newEmail = document.getElementById('email').value; const newPhone = document.getElementById('phone').value; - // Since dashboard.js manages the span values via ID, we just update the text content of the spans - const nameSpan = document.getElementById('profile-view-name'); - const emailSpan = document.getElementById('profile-view-email'); - const phoneSpan = document.getElementById('profile-view-phone'); - - if (nameSpan) nameSpan.innerText = newName; - if (emailSpan) emailSpan.innerText = newEmail; - if (phoneSpan) phoneSpan.innerText = newPhone; - - profileEditForm.classList.add('hidden'); - profileView.classList.remove('hidden'); - profileActions.classList.add('hidden'); - editProfileBtn.classList.remove('hidden'); + try { + const response = await fetch(`/u/${username}/profile/edit`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + full_name: newName, + email: newEmail, + phone_number: newPhone + }) + }); + + const result = await response.json(); + + if (!result.success) { + alert(result.message || 'Failed to update profile'); + return; + } + + const nameSpan = document.getElementById('profile-view-name'); + const emailSpan = document.getElementById('profile-view-email'); + const phoneSpan = document.getElementById('profile-view-phone'); + + if (nameSpan) nameSpan.innerText = newName; + if (emailSpan) emailSpan.innerText = newEmail; + if (phoneSpan) phoneSpan.innerText = newPhone; + + profileEditForm.classList.add('hidden'); + profileView.classList.remove('hidden'); + profileActions.classList.add('hidden'); + editProfileBtn.classList.remove('hidden'); + + } catch (err) { + console.error('Profile update error:', err); + alert('Network error, please try again.'); + } }); } @@ -55,7 +83,7 @@ document.addEventListener("DOMContentLoaded", function () { const confirmPasswordInput = document.getElementById('confirm_password'); const passwordSubmitBtn = document.getElementById('change-password-btn'); const passwordErrorMsg = document.getElementById('password-error-message'); - + // Checklist elements const checklist = document.getElementById('password-checklist'); const lengthCheck = document.getElementById('length-check'); @@ -112,20 +140,48 @@ document.addEventListener("DOMContentLoaded", function () { newPasswordInput.addEventListener('input', validatePasswordForm); confirmPasswordInput.addEventListener('input', validatePasswordForm); - passwordForm.addEventListener('submit', (e) => { + passwordForm.addEventListener('submit', async (e) => { e.preventDefault(); + if (passwordSubmitBtn.disabled) { passwordErrorMsg.textContent = 'Please fix the errors in the password checklist.'; passwordErrorMsg.classList.remove('hidden'); - } else { - // --- FRONTEND-ONLY DEMO --- - // In a real app, you'd send a fetch() request to your backend - // to verify the *current* password and set the new one. + return; + } + + const currentPassword = document.getElementById('current_password').value; + const newPassword = newPasswordInput.value; + const confirmPassword = confirmPasswordInput.value; + + try { + const response = await fetch(`/u/${username}/profile/password`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword, + confirm_password: confirmPassword + }) + }); + + const result = await response.json(); + + if (!result.success) { + passwordErrorMsg.textContent = result.message || 'Failed to change password'; + passwordErrorMsg.classList.remove('hidden'); + return; + } + passwordErrorMsg.classList.add('hidden'); alert('Password changed successfully!'); passwordForm.reset(); checklist.classList.add('hidden'); passwordSubmitBtn.disabled = true; + + } catch (err) { + console.error('Password change error:', err); + passwordErrorMsg.textContent = 'Network error, please try again.'; + passwordErrorMsg.classList.remove('hidden'); } }); } @@ -140,7 +196,7 @@ document.addEventListener("DOMContentLoaded", function () { const deleteConfirmText = document.getElementById('delete-confirm-text'); if (deleteAccountBtn && deleteModal && deleteModalContent && deleteModalCloseBtn && deleteModalCancelBtn && deleteModalConfirmBtn && deleteConfirmText) { - + function openDeleteModal() { deleteModal.classList.remove('hidden'); setTimeout(() => { @@ -152,21 +208,19 @@ document.addEventListener("DOMContentLoaded", function () { function closeDeleteModal() { deleteModalContent.classList.add('scale-95', 'opacity-0'); - deleteModalContent.classList.remove('scale-100', 'opacity-1ci00'); + deleteModalContent.classList.remove('scale-100', 'opacity-100'); deleteModal.classList.add('hidden', 'opacity-0'); - - // Reset the form in the modal + deleteConfirmText.value = ''; deleteModalConfirmBtn.disabled = true; deleteModalConfirmBtn.classList.add('bg-gray-400', 'cursor-not-allowed'); deleteModalConfirmBtn.classList.remove('bg-red-600', 'hover:bg-red-700'); - + setTimeout(() => { deleteModal.classList.add('hidden'); }, 300); } - // Check if user has typed "DELETE" deleteConfirmText.addEventListener('input', () => { if (deleteConfirmText.value === 'DELETE') { deleteModalConfirmBtn.disabled = false; @@ -182,7 +236,7 @@ document.addEventListener("DOMContentLoaded", function () { deleteAccountBtn.addEventListener('click', openDeleteModal); deleteModalCloseBtn.addEventListener('click', closeDeleteModal); deleteModalCancelBtn.addEventListener('click', closeDeleteModal); - + deleteModal.addEventListener('click', (e) => { if (e.target === deleteModal) { closeDeleteModal(); @@ -191,7 +245,7 @@ document.addEventListener("DOMContentLoaded", function () { deleteModalConfirmBtn.addEventListener('click', () => { // --- FRONTEND-ONLY DEMO --- - // In a real app, send fetch() request to delete the user + // Not yet wired to backend alert('Account deleted. Redirecting...'); window.location.href = "paycard_login.html"; }); diff --git a/frontend/templates/customer/profile.html b/frontend/templates/customer/profile.html index 03cc26f..35e68eb 100644 --- a/frontend/templates/customer/profile.html +++ b/frontend/templates/customer/profile.html @@ -12,7 +12,7 @@ -
+