fix(backend): address gift card review findings

Fix #1: remove expiry_date from gift card INSERTs (rolling 24-month via last_used_at only)

Fix #2: add FOR UPDATE to RedeemGiftCard SELECT (race condition)

Fix #6: add ?type=customer|inventory filter to GetGiftCards

Fix #7: add admin_audit_log INSERT to GetUserGiftCardBalanceAdmin

Fix #10: refactor normalizeCode to validators.NormalizeGiftCardCode

Schema: add admin_audit_log table (GDPR Article 30), update testdb.go

Tests: expiry-date-null, inventory-filter, audit-log, db table references

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-12 10:50:49 +01:00
co-authored by Sisyphus
parent e7fd9c89eb
commit 3c447a5c33
6 changed files with 399 additions and 154 deletions
+97 -92
View File
@@ -4,9 +4,11 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"crussell/db" "crussell/db"
@@ -123,7 +125,7 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
`).Scan(&resp.TotalUnclaimed) `).Scan(&resp.TotalUnclaimed)
if err != nil { if err != nil {
log.Printf("Failed to calculate total unclaimed gift cards: %v", err) log.Printf("Failed to calculate total unclaimed gift cards: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -133,60 +135,62 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
`).Scan(&resp.TotalUserBalances) `).Scan(&resp.TotalUserBalances)
if err != nil { if err != nil {
log.Printf("Failed to calculate total user balances: %v", err) log.Printf("Failed to calculate total user balances: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
// --- Gift cards (paginated, optionally filtered) --- // --- Gift cards (paginated, optionally filtered) ---
filterType := query.Get("type")
whereClauses := []string{}
if searchTerm != "" {
whereClauses = append(whereClauses, "id ILIKE $1")
}
if filterType == "customer" {
whereClauses = append(whereClauses, "is_inventory = FALSE")
} else if filterType == "inventory" {
whereClauses = append(whereClauses, "is_inventory = TRUE")
}
whereSQL := ""
if len(whereClauses) > 0 {
whereSQL = "WHERE " + strings.Join(whereClauses, " AND ")
}
var gcTotal int var gcTotal int
var gcCountQuery string
var gcListQuery string
var gcCountArgs []interface{} var gcCountArgs []interface{}
var gcListArgs []interface{} var gcListArgs []interface{}
gcCountQuery := fmt.Sprintf(`SELECT COUNT(*) FROM gift_cards %s`, whereSQL)
gcListQuery := fmt.Sprintf(`
SELECT id, total_funds_added, amount_remaining, created_by, created_at, redeemed_at, redeemed_by, is_inventory, last_used_at
FROM gift_cards
%s
ORDER BY created_at DESC
LIMIT $%%d OFFSET $%%d
`, whereSQL)
if searchTerm != "" { if searchTerm != "" {
searchPattern := "%" + searchTerm + "%" searchPattern := "%" + searchTerm + "%"
gcCountQuery = `
SELECT COUNT(*)
FROM gift_cards
WHERE id ILIKE $1
`
gcCountArgs = []interface{}{searchPattern} gcCountArgs = []interface{}{searchPattern}
gcListQuery = `
SELECT id, total_funds_added, amount_remaining, created_by, created_at, redeemed_at, redeemed_by, is_inventory, last_used_at
FROM gift_cards
WHERE id ILIKE $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
gcListArgs = []interface{}{searchPattern, perPage, offset} gcListArgs = []interface{}{searchPattern, perPage, offset}
gcListQuery = fmt.Sprintf(gcListQuery, 2, 3)
} else { } else {
gcCountQuery = `SELECT COUNT(*) FROM gift_cards`
gcCountArgs = []interface{}{}
gcListQuery = `
SELECT id, total_funds_added, amount_remaining, created_by, created_at, redeemed_at, redeemed_by, is_inventory, last_used_at
FROM gift_cards
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`
gcListArgs = []interface{}{perPage, offset} gcListArgs = []interface{}{perPage, offset}
gcListQuery = fmt.Sprintf(gcListQuery, 1, 2)
} }
err = db.DB.QueryRow(ctx, gcCountQuery, gcCountArgs...).Scan(&gcTotal) err = db.DB.QueryRow(ctx, gcCountQuery, gcCountArgs...).Scan(&gcTotal)
if err != nil { if err != nil {
log.Printf("Failed to count gift cards: %v", err) log.Printf("Failed to count gift cards: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
gcRows, err := db.DB.Query(ctx, gcListQuery, gcListArgs...) gcRows, err := db.DB.Query(ctx, gcListQuery, gcListArgs...)
if err != nil { if err != nil {
log.Printf("Failed to query gift cards: %v", err) log.Printf("Failed to query gift cards: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
defer gcRows.Close() defer gcRows.Close()
@@ -209,7 +213,7 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
) )
if err != nil { if err != nil {
log.Printf("Failed to scan gift card: %v", err) log.Printf("Failed to scan gift card: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -263,7 +267,7 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
ubRows, err := db.DB.Query(ctx, ubListQuery, ubListArgs...) ubRows, err := db.DB.Query(ctx, ubListQuery, ubListArgs...)
if err != nil { if err != nil {
log.Printf("Failed to query user balances: %v", err) log.Printf("Failed to query user balances: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
defer ubRows.Close() defer ubRows.Close()
@@ -281,7 +285,7 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
) )
if err != nil { if err != nil {
log.Printf("Failed to scan user balance: %v", err) log.Printf("Failed to scan user balance: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
if ubTotal == 0 { if ubTotal == 0 {
@@ -309,7 +313,7 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) {
var req CreateGiftCardRequest var req CreateGiftCardRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
@@ -325,7 +329,7 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) {
tx, err := db.DB.Begin(ctx) tx, err := db.DB.Begin(ctx)
if err != nil { if err != nil {
log.Printf("Failed to begin transaction: %v", err) log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
@@ -347,7 +351,7 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) {
) )
if err != nil { if err != nil {
log.Printf("Failed to create gift card: %v", err) log.Printf("Failed to create gift card: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -369,13 +373,13 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) {
`, gc.ID, req.Amount, adminID, notes) `, gc.ID, req.Amount, adminID, notes)
if err != nil { if err != nil {
log.Printf("Failed to record gift card transaction: %v", err) log.Printf("Failed to record gift card transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
if err := tx.Commit(ctx); err != nil { if err := tx.Commit(ctx); err != nil {
log.Printf("Failed to commit transaction: %v", err) log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -395,7 +399,7 @@ func TopUpGiftCard(w http.ResponseWriter, r *http.Request) {
var req TopUpGiftCardRequest var req TopUpGiftCardRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
@@ -419,7 +423,7 @@ func TopUpGiftCard(w http.ResponseWriter, r *http.Request) {
tx, err := db.DB.Begin(ctx) tx, err := db.DB.Begin(ctx)
if err != nil { if err != nil {
log.Printf("Failed to begin transaction: %v", err) log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
@@ -434,7 +438,7 @@ func TopUpGiftCard(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("Failed to check gift card: %v", err) log.Printf("Failed to check gift card: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -469,7 +473,7 @@ func TopUpGiftCard(w http.ResponseWriter, r *http.Request) {
) )
if err != nil { if err != nil {
log.Printf("Failed to top up gift card: %v", err) log.Printf("Failed to top up gift card: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -483,13 +487,13 @@ func TopUpGiftCard(w http.ResponseWriter, r *http.Request) {
`, cardID, txType, req.Amount, adminID, notes) `, cardID, txType, req.Amount, adminID, notes)
if err != nil { if err != nil {
log.Printf("Failed to record gift card transaction: %v", err) log.Printf("Failed to record gift card transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
if err := tx.Commit(ctx); err != nil { if err := tx.Commit(ctx); err != nil {
log.Printf("Failed to commit transaction: %v", err) log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -507,11 +511,11 @@ func TransferGiftCard(w http.ResponseWriter, r *http.Request) {
var req TransferGiftCardRequest var req TransferGiftCardRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
req.ToCardID = normalizeCode(req.ToCardID) req.ToCardID = validators.NormalizeGiftCardCode(req.ToCardID)
if !validators.IsValidID(req.ToCardID) { if !validators.IsValidID(req.ToCardID) {
http.Error(w, "Invalid destination gift card ID", http.StatusBadRequest) http.Error(w, "Invalid destination gift card ID", http.StatusBadRequest)
return return
@@ -530,7 +534,7 @@ func TransferGiftCard(w http.ResponseWriter, r *http.Request) {
tx, err := db.DB.Begin(ctx) tx, err := db.DB.Begin(ctx)
if err != nil { if err != nil {
log.Printf("Failed to begin transaction: %v", err) log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
@@ -545,7 +549,7 @@ func TransferGiftCard(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("Failed to check source gift card: %v", err) log.Printf("Failed to check source gift card: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -556,7 +560,7 @@ func TransferGiftCard(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("Failed to check destination gift card: %v", err) log.Printf("Failed to check destination gift card: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -578,7 +582,7 @@ func TransferGiftCard(w http.ResponseWriter, r *http.Request) {
`, req.Amount, fromCardID) `, req.Amount, fromCardID)
if err != nil { if err != nil {
log.Printf("Failed to deduct from source: %v", err) log.Printf("Failed to deduct from source: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -591,13 +595,13 @@ func TransferGiftCard(w http.ResponseWriter, r *http.Request) {
`, req.Amount, req.ToCardID) `, req.Amount, req.ToCardID)
if err != nil { if err != nil {
log.Printf("Failed to add to destination: %v", err) log.Printf("Failed to add to destination: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
if err := tx.Commit(ctx); err != nil { if err := tx.Commit(ctx); err != nil {
log.Printf("Failed to commit transaction: %v", err) log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -617,11 +621,11 @@ func RedeemGiftCard(w http.ResponseWriter, r *http.Request) {
var req RedeemGiftCardRequest var req RedeemGiftCardRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
code := normalizeCode(req.Code) code := validators.NormalizeGiftCardCode(req.Code)
if !validators.IsValidID(code) { if !validators.IsValidID(code) {
http.Error(w, "Invalid gift card code format", http.StatusBadRequest) http.Error(w, "Invalid gift card code format", http.StatusBadRequest)
return return
@@ -630,7 +634,7 @@ func RedeemGiftCard(w http.ResponseWriter, r *http.Request) {
tx, err := db.DB.Begin(ctx) tx, err := db.DB.Begin(ctx)
if err != nil { if err != nil {
log.Printf("Failed to begin transaction: %v", err) log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
@@ -641,6 +645,7 @@ func RedeemGiftCard(w http.ResponseWriter, r *http.Request) {
SELECT amount_remaining, redeemed_by SELECT amount_remaining, redeemed_by
FROM gift_cards FROM gift_cards
WHERE id = $1 WHERE id = $1
FOR UPDATE
`, code).Scan(&amountRemaining, &redeemedBy) `, code).Scan(&amountRemaining, &redeemedBy)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
@@ -648,7 +653,7 @@ func RedeemGiftCard(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("Failed to query gift card: %v", err) log.Printf("Failed to query gift card: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -671,7 +676,7 @@ func RedeemGiftCard(w http.ResponseWriter, r *http.Request) {
`, userID, code) `, userID, code)
if err != nil { if err != nil {
log.Printf("Failed to update gift card: %v", err) log.Printf("Failed to update gift card: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -684,7 +689,7 @@ func RedeemGiftCard(w http.ResponseWriter, r *http.Request) {
`, userID, amountRemaining) `, userID, amountRemaining)
if err != nil { if err != nil {
log.Printf("Failed to update user gift card balance: %v", err) log.Printf("Failed to update user gift card balance: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -694,13 +699,13 @@ func RedeemGiftCard(w http.ResponseWriter, r *http.Request) {
`, code, amountRemaining, userID) `, code, amountRemaining, userID)
if err != nil { if err != nil {
log.Printf("Failed to record gift card transaction: %v", err) log.Printf("Failed to record gift card transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
if err := tx.Commit(ctx); err != nil { if err := tx.Commit(ctx); err != nil {
log.Printf("Failed to commit transaction: %v", err) log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -728,7 +733,7 @@ func GetGiftCardBalance(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("Failed to query user balance: %v", err) log.Printf("Failed to query user balance: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -745,6 +750,8 @@ func GetUserGiftCardBalanceAdmin(w http.ResponseWriter, r *http.Request) {
return return
} }
adminID, _ := ctx.Value(mw.UserIDKey).(string)
var balance float64 var balance float64
err := db.DB.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance) err := db.DB.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
if err != nil { if err != nil {
@@ -754,10 +761,18 @@ func GetUserGiftCardBalanceAdmin(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("Failed to query user balance: %v", err) log.Printf("Failed to query user balance: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
detailsJSON := fmt.Sprintf(`{"balance": %.2f}`, balance)
if _, err := db.DB.Exec(ctx, `
INSERT INTO admin_audit_log (admin_id, action_type, target_user_id, details)
VALUES ($1, 'balance_check', $2, $3::jsonb)
`, adminID, userID, detailsJSON); err != nil {
log.Printf("Failed to record admin_audit_log (non-critical): %v", err)
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]float64{"balance": balance}) json.NewEncoder(w).Encode(map[string]float64{"balance": balance})
} }
@@ -772,7 +787,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
var req BuyGiftCardRequest var req BuyGiftCardRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
@@ -835,7 +850,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("Failed to get card: %v", err) log.Printf("Failed to get card: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
sourceID = card.SquareCardID sourceID = card.SquareCardID
@@ -860,7 +875,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
tx, err := db.DB.Begin(ctx) tx, err := db.DB.Begin(ctx)
if err != nil { if err != nil {
log.Printf("Failed to begin transaction: %v", err) log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
@@ -870,15 +885,14 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
var cardID string var cardID string
if req.RecipientType == "self" { if req.RecipientType == "self" {
expiryDate := time.Now().AddDate(1, 0, 0)
err = tx.QueryRow(ctx, ` err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by, is_inventory, expiry_date) INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by, is_inventory)
VALUES ($1, 0, $2, NOW(), $2, FALSE, $3) VALUES ($1, 0, $2, NOW(), $2, FALSE)
RETURNING id RETURNING id
`, amountPounds, userID, expiryDate).Scan(&cardID) `, amountPounds, userID).Scan(&cardID)
if err != nil { if err != nil {
log.Printf("Failed to insert gift card: %v", err) log.Printf("Failed to insert gift card: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -891,7 +905,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
`, userID, amountPounds) `, userID, amountPounds)
if err != nil { if err != nil {
log.Printf("Failed to update balance: %v", err) log.Printf("Failed to update balance: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -901,19 +915,18 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
`, cardID, amountPounds, userID) `, cardID, amountPounds, userID)
if err != nil { if err != nil {
log.Printf("Failed to record gift card transaction: %v", err) log.Printf("Failed to record gift card transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
} else { } else {
expiryDate := time.Now().AddDate(1, 0, 0)
err = tx.QueryRow(ctx, ` err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, expiry_date) INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory)
VALUES ($1, $1, $2, FALSE, $3) VALUES ($1, $1, $2, FALSE)
RETURNING id RETURNING id
`, amountPounds, userID, expiryDate).Scan(&cardID) `, amountPounds, userID).Scan(&cardID)
if err != nil { if err != nil {
log.Printf("Failed to insert gift card: %v", err) log.Printf("Failed to insert gift card: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -931,7 +944,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
`, cardID, amountPounds, userID) `, cardID, amountPounds, userID)
if err != nil { if err != nil {
log.Printf("Failed to record gift card transaction: %v", err) log.Printf("Failed to record gift card transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
} }
@@ -957,13 +970,13 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
`, record.PaymentType, record.PaymentMethod, record.Status, record.Amount, record.SquarePaymentID, record.IdempotencyKey, record.Fees, record.UserSavedCardID, record.CreatedBy, record.CreatedAt, record.UpdatedAt) `, record.PaymentType, record.PaymentMethod, record.Status, record.Amount, record.SquarePaymentID, record.IdempotencyKey, record.Fees, record.UserSavedCardID, record.CreatedBy, record.CreatedAt, record.UpdatedAt)
if err != nil { if err != nil {
log.Printf("Failed to insert payment record: %v", err) log.Printf("Failed to insert payment record: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
if err := tx.Commit(ctx); err != nil { if err := tx.Commit(ctx); err != nil {
log.Printf("Failed to commit buy transaction: %v", err) log.Printf("Failed to commit buy transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -978,15 +991,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
// --- Helpers --- // --- Helpers ---
func normalizeCode(code string) string {
clean := ""
for _, char := range code {
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') {
clean += string(char)
}
}
return clean
}
type ExpiredBalance struct { type ExpiredBalance struct {
ID string `json:"id"` ID string `json:"id"`
@@ -1008,7 +1013,7 @@ func GetExpiredBalances(w http.ResponseWriter, r *http.Request) {
`) `)
if err != nil { if err != nil {
log.Printf("Failed to query expired balances: %v", err) log.Printf("Failed to query expired balances: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
defer rows.Close() defer rows.Close()
@@ -1022,7 +1027,7 @@ func GetExpiredBalances(w http.ResponseWriter, r *http.Request) {
err = rows.Scan(&b.ID, &accountID, &b.OriginalBalance, &b.ExpiredAt, &claimedAt, &claimedByAdmin, &notes) err = rows.Scan(&b.ID, &accountID, &b.OriginalBalance, &b.ExpiredAt, &claimedAt, &claimedByAdmin, &notes)
if err != nil { if err != nil {
log.Printf("Failed to scan expired balance: %v", err) log.Printf("Failed to scan expired balance: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -1087,7 +1092,7 @@ func ClaimExpiredBalance(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("Failed to check expired balance: %v", err) log.Printf("Failed to check expired balance: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -1103,7 +1108,7 @@ func ClaimExpiredBalance(w http.ResponseWriter, r *http.Request) {
`, adminID, req.Notes, req.BalanceID) `, adminID, req.Notes, req.BalanceID)
if err != nil { if err != nil {
log.Printf("Failed to claim expired balance: %v", err) log.Printf("Failed to claim expired balance: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
+227
View File
@@ -11,6 +11,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time"
"crussell/db" "crussell/db"
"crussell/mw" "crussell/mw"
@@ -1121,3 +1122,229 @@ func TestBuyGiftCard_Idempotency(t *testing.T) {
t.Errorf("after second request: expected 1 gift card record, got %d", cardCount2) t.Errorf("after second request: expected 1 gift card record, got %d", cardCount2)
} }
} }
// TestAdminCreateGiftCard_ExpiryDateIsNull verifies that gift cards created via
// CreateGiftCard no longer have expiry_date set (rolling 24-month expiry via last_used_at).
func TestAdminCreateGiftCard_ExpiryDateIsNull(t *testing.T) {
resetGiftCardsTestData(t)
ctx := context.Background()
adminID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
_, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
token := jwt.GenerateTestToken(adminID, "admin")
reqBody, _ := json.Marshal(map[string]interface{}{"amount": 50.00})
req := httptest.NewRequest("POST", "/api/admin/gift-cards", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/gift-cards", CreateGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", w.Code)
}
var gc GiftCard
if err := json.NewDecoder(w.Body).Decode(&gc); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
// Verify expiry_date is NULL in the database
var expiryDate *time.Time
err = db.DB.QueryRow(ctx, `SELECT expiry_date FROM gift_cards WHERE id = $1`, gc.ID).Scan(&expiryDate)
if err != nil {
t.Fatalf("failed to query gift card expiry_date: %v", err)
}
if expiryDate != nil {
t.Error("expected expiry_date to be NULL for rolling-expiry gift cards")
}
}
// TestGetGiftCards_InventoryFilter verifies the ?type=customer|inventory query parameter.
func TestGetGiftCards_InventoryFilter(t *testing.T) {
resetGiftCardsTestData(t)
ctx := context.Background()
adminID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
_, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
token := jwt.GenerateTestToken(adminID, "admin")
// Insert one customer card and one inventory card
_, err = db.DB.Exec(ctx, `INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory) VALUES (10.00, 10.00, $1, FALSE)`, adminID)
if err != nil {
t.Fatalf("failed to create customer card: %v", err)
}
_, err = db.DB.Exec(ctx, `INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory) VALUES (20.00, 20.00, $1, TRUE)`, adminID)
if err != nil {
t.Fatalf("failed to create inventory card: %v", err)
}
// Test ?type=customer filter
reqCustomer := httptest.NewRequest("GET", "/api/admin/gift-cards?type=customer", nil)
reqCustomer.Header.Set("Authorization", "Bearer "+token)
wCustomer := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Get("/api/admin/gift-cards", GetGiftCards)
r.ServeHTTP(wCustomer, reqCustomer)
if wCustomer.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", wCustomer.Code)
}
var respCustomer GiftCardListResponse
if err := json.NewDecoder(wCustomer.Body).Decode(&respCustomer); err != nil {
t.Fatalf("failed to decode customer response: %v", err)
}
customerCount := 0
inventoryCount := 0
for _, gc := range respCustomer.GiftCards {
if gc.IsInventory {
inventoryCount++
} else {
customerCount++
}
}
if respCustomer.Total != 1 {
t.Errorf("expected total 1 (customer cards only), got %d", respCustomer.Total)
}
if customerCount != 1 {
t.Errorf("expected 1 customer card in filtered list, got %d", customerCount)
}
if inventoryCount != 0 {
t.Errorf("expected 0 inventory cards in customer filter, got %d", inventoryCount)
}
// Test ?type=inventory filter
reqInventory := httptest.NewRequest("GET", "/api/admin/gift-cards?type=inventory", nil)
reqInventory.Header.Set("Authorization", "Bearer "+token)
wInventory := httptest.NewRecorder()
rInventory := chi.NewRouter()
rInventory.Use(mw.RequireAuth)
rInventory.Get("/api/admin/gift-cards", GetGiftCards)
rInventory.ServeHTTP(wInventory, reqInventory)
if wInventory.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", wInventory.Code)
}
var respInventory GiftCardListResponse
if err := json.NewDecoder(wInventory.Body).Decode(&respInventory); err != nil {
t.Fatalf("failed to decode inventory response: %v", err)
}
if respInventory.Total != 1 {
t.Errorf("expected total 1 (inventory cards only), got %d", respInventory.Total)
}
for _, gc := range respInventory.GiftCards {
if !gc.IsInventory {
t.Errorf("expected only inventory cards, got customer card %s", gc.ID)
}
}
// Test no filter (should return both)
reqAll := httptest.NewRequest("GET", "/api/admin/gift-cards", nil)
reqAll.Header.Set("Authorization", "Bearer "+token)
wAll := httptest.NewRecorder()
rAll := chi.NewRouter()
rAll.Use(mw.RequireAuth)
rAll.Get("/api/admin/gift-cards", GetGiftCards)
rAll.ServeHTTP(wAll, reqAll)
if wAll.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", wAll.Code)
}
var respAll GiftCardListResponse
if err := json.NewDecoder(wAll.Body).Decode(&respAll); err != nil {
t.Fatalf("failed to decode all response: %v", err)
}
if respAll.Total != 2 {
t.Errorf("expected total 2 (all cards), got %d", respAll.Total)
}
}
// TestGetUserGiftCardBalanceAdmin_AuditLog verifies admin balance checks
// are recorded in the admin_audit_log table.
func TestGetUserGiftCardBalanceAdmin_AuditLog(t *testing.T) {
resetGiftCardsTestData(t)
ctx := context.Background()
adminID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
_, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
// Give the user a balance
_, err = db.DB.Exec(ctx, `INSERT INTO user_giftcard_balances (user_id, balance) VALUES ($1, 42.50)`, userID)
if err != nil {
t.Fatalf("failed to insert user balance: %v", err)
}
token := jwt.GenerateTestToken(adminID, "admin")
// Set up request with chi route context for URL param extraction
req := httptest.NewRequest("GET", "/"+userID+"/giftcard-balance", nil)
req.Header.Set("Authorization", "Bearer "+token)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", userID)
ctxWithRoute := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
req = req.WithContext(ctxWithRoute)
// Add user context (from token)
info := extractUserFromTestJWT(token)
if info != nil {
reqCtx := context.WithValue(req.Context(), mw.UserIDKey, info.userID)
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, info.role)
req = req.WithContext(reqCtx)
}
w := httptest.NewRecorder()
GetUserGiftCardBalanceAdmin(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. Body: %s", w.Code, w.Body.String())
}
var resp map[string]float64
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["balance"] != 42.50 {
t.Errorf("expected balance 42.50, got %.2f", resp["balance"])
}
// Verify audit log entry was created
var logCount int
err = db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM admin_audit_log WHERE admin_id = $1 AND target_user_id = $2 AND action_type = 'balance_check'`, adminID, userID).Scan(&logCount)
if err != nil {
t.Fatalf("failed to query admin_audit_log: %v", err)
}
if logCount != 1 {
t.Errorf("expected 1 audit log entry, got %d", logCount)
}
}
+32 -32
View File
@@ -106,7 +106,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
var req CreateTerminalPaymentRequest var req CreateTerminalPaymentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("Failed to decode terminal payment request: %v", err) log.Printf("Failed to decode terminal payment request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
@@ -129,7 +129,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("Failed to get booking status: %v", err) log.Printf("Failed to get booking status: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -163,7 +163,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
tx, err := db.DB.Begin(r.Context()) tx, err := db.DB.Begin(r.Context())
if err != nil { if err != nil {
log.Printf("Failed to begin transaction: %v", err) log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
defer tx.Rollback(r.Context()) defer tx.Rollback(r.Context())
@@ -180,7 +180,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
`, bookingID, req.PaymentType, amountPounds, adminID).Scan(&paymentID) `, bookingID, req.PaymentType, amountPounds, adminID).Scan(&paymentID)
if err != nil { if err != nil {
log.Printf("Failed to create cash payment record: %v", err) log.Printf("Failed to create cash payment record: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
} else { // giftcard } else { // giftcard
@@ -188,7 +188,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
err = tx.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&customerID) err = tx.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&customerID)
if err != nil { if err != nil {
log.Printf("Failed to query booking user: %v", err) log.Printf("Failed to query booking user: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -205,13 +205,13 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
_, err = tx.Exec(r.Context(), "UPDATE user_giftcard_balances SET balance = balance - $1, updated_at = NOW() WHERE user_id = $2", amountPounds, customerID.String) _, err = tx.Exec(r.Context(), "UPDATE user_giftcard_balances SET balance = balance - $1, updated_at = NOW() WHERE user_id = $2", amountPounds, customerID.String)
if err != nil { if err != nil {
log.Printf("Failed to deduct user balance: %v", err) log.Printf("Failed to deduct user balance: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
usedBalance = true usedBalance = true
} else if !errors.Is(err, pgx.ErrNoRows) { } else if !errors.Is(err, pgx.ErrNoRows) {
log.Printf("Failed to query user balance: %v", err) log.Printf("Failed to query user balance: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
} }
@@ -222,7 +222,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Gift card ID is required", http.StatusBadRequest) http.Error(w, "Gift card ID is required", http.StatusBadRequest)
return return
} }
cleanCardID := normalizeCode(*req.GiftCardID) cleanCardID := validators.NormalizeGiftCardCode(*req.GiftCardID)
var gcRemaining float64 var gcRemaining float64
var redeemedBy sql.NullString var redeemedBy sql.NullString
@@ -233,7 +233,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("Failed to query gift card: %v", err) log.Printf("Failed to query gift card: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -251,7 +251,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
_, err = tx.Exec(r.Context(), "UPDATE gift_cards SET amount_remaining = amount_remaining - $1, last_used_at = NOW() WHERE id = $2", amountPounds, cleanCardID) _, err = tx.Exec(r.Context(), "UPDATE gift_cards SET amount_remaining = amount_remaining - $1, last_used_at = NOW() WHERE id = $2", amountPounds, cleanCardID)
if err != nil { if err != nil {
log.Printf("Failed to deduct gift card amount: %v", err) log.Printf("Failed to deduct gift card amount: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
} }
@@ -264,14 +264,14 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
`, bookingID, req.PaymentType, amountPounds, adminID).Scan(&paymentID) `, bookingID, req.PaymentType, amountPounds, adminID).Scan(&paymentID)
if err != nil { if err != nil {
log.Printf("Failed to create giftcard payment record: %v", err) log.Printf("Failed to create giftcard payment record: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
} }
if err := tx.Commit(r.Context()); err != nil { if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit payment: %v", err) log.Printf("Failed to commit payment: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -369,7 +369,7 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) {
paymentID, err := service.CreatePaymentRecord(r.Context(), record, nil) paymentID, err := service.CreatePaymentRecord(r.Context(), record, nil)
if err != nil { if err != nil {
log.Printf("Failed to create payment record: %v", err) log.Printf("Failed to create payment record: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -404,7 +404,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
var req CreateBookingPaymentRequest var req CreateBookingPaymentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("Failed to decode booking payment request: %v", err) log.Printf("Failed to decode booking payment request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
@@ -429,7 +429,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
remainingCents, err := service.GetBookingRemainingBalanceCents(r.Context(), bookingID) remainingCents, err := service.GetBookingRemainingBalanceCents(r.Context(), bookingID)
if err != nil { if err != nil {
log.Printf("Failed to get remaining balance: %v", err) log.Printf("Failed to get remaining balance: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
if err := ValidatePartialAmount(req.Amount, remainingCents); err != nil { if err := ValidatePartialAmount(req.Amount, remainingCents); err != nil {
@@ -445,7 +445,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("Failed to get booking user: %v", err) log.Printf("Failed to get booking user: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -500,7 +500,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("Failed to get card: %v", err) log.Printf("Failed to get card: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
sourceID = card.SquareCardID sourceID = card.SquareCardID
@@ -543,7 +543,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
paymentID, err := service.CreatePaymentRecord(r.Context(), record, nil) paymentID, err := service.CreatePaymentRecord(r.Context(), record, nil)
if err != nil { if err != nil {
log.Printf("Failed to create payment record: %v", err) log.Printf("Failed to create payment record: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -579,7 +579,7 @@ func GetUserPaymentMethods(w http.ResponseWriter, r *http.Request) {
cards, err := service.GetUserPaymentMethods(r.Context(), userID) cards, err := service.GetUserPaymentMethods(r.Context(), userID)
if err != nil { if err != nil {
log.Printf("Failed to get payment methods: %v", err) log.Printf("Failed to get payment methods: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -598,7 +598,7 @@ func AdminGetUserPaymentMethods(w http.ResponseWriter, r *http.Request) {
cards, err := service.GetUserPaymentMethods(r.Context(), userID) cards, err := service.GetUserPaymentMethods(r.Context(), userID)
if err != nil { if err != nil {
log.Printf("Failed to get payment methods for user %s: %v", userID, err) log.Printf("Failed to get payment methods for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -623,7 +623,7 @@ func DeletePaymentMethod(w http.ResponseWriter, r *http.Request) {
err := service.DeletePaymentMethod(r.Context(), cardID, userID) err := service.DeletePaymentMethod(r.Context(), cardID, userID)
if err != nil { if err != nil {
log.Printf("Failed to delete payment method: %v", err) log.Printf("Failed to delete payment method: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -646,7 +646,7 @@ func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) {
var req CreatePaymentMethodRequest var req CreatePaymentMethodRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
@@ -687,7 +687,7 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
var req RefundRequest var req RefundRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("Failed to decode refund request: %v", err) log.Printf("Failed to decode refund request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
@@ -710,7 +710,7 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("Failed to get payment: %v", err) log.Printf("Failed to get payment: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -727,7 +727,7 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
alreadyRefunded, err := service.GetAlreadyRefundedAmount(r.Context(), paymentID) alreadyRefunded, err := service.GetAlreadyRefundedAmount(r.Context(), paymentID)
if err != nil { if err != nil {
log.Printf("Failed to get already refunded amount: %v", err) log.Printf("Failed to get already refunded amount: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -765,7 +765,7 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
refundID, err := service.CreateRefundRecord(r.Context(), record) refundID, err := service.CreateRefundRecord(r.Context(), record)
if err != nil { if err != nil {
log.Printf("Failed to create refund record: %v", err) log.Printf("Failed to create refund record: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -803,7 +803,7 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
var req CreateTipPaymentRequest var req CreateTipPaymentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("Failed to decode tip payment request: %v", err) log.Printf("Failed to decode tip payment request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
@@ -826,7 +826,7 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("Failed to get booking user: %v", err) log.Printf("Failed to get booking user: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -838,7 +838,7 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
hasCompleted, err := service.HasCompletedPayment(r.Context(), bookingID) hasCompleted, err := service.HasCompletedPayment(r.Context(), bookingID)
if err != nil { if err != nil {
log.Printf("Failed to check for completed payments: %v", err) log.Printf("Failed to check for completed payments: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -882,7 +882,7 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
paymentID, err := service.CreatePaymentRecord(r.Context(), record, nil) paymentID, err := service.CreatePaymentRecord(r.Context(), record, nil)
if err != nil { if err != nil {
log.Printf("Failed to create payment record: %v", err) log.Printf("Failed to create payment record: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -920,7 +920,7 @@ func GetBookingPaymentSummary(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("Failed to get booking user: %v", err) log.Printf("Failed to get booking user: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -933,7 +933,7 @@ func GetBookingPaymentSummary(w http.ResponseWriter, r *http.Request) {
summary, err := service.GetBookingPaymentSummary(r.Context(), bookingID) summary, err := service.GetBookingPaymentSummary(r.Context(), bookingID)
if err != nil { if err != nil {
log.Printf("Failed to get payment summary: %v", err) log.Printf("Failed to get payment summary: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
+21 -27
View File
@@ -3,6 +3,7 @@ package payments
import ( import (
"crussell/db" "crussell/db"
"crussell/internal/square" "crussell/internal/square"
"crussell/internal/validators"
"crussell/mw" "crussell/mw"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
@@ -48,7 +49,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
var req TillSaleRequest var req TillSaleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
@@ -99,19 +100,12 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
} }
} }
var expiryMonths int
err := db.DB.QueryRow(ctx, `SELECT COALESCE(gift_card_expiry_months, 12) FROM business_settings LIMIT 1`).Scan(&expiryMonths)
if err != nil {
expiryMonths = 12
}
expiryDate := time.Now().AddDate(0, expiryMonths, 0)
service := NewPaymentService() service := NewPaymentService()
tx, err := db.DB.Begin(ctx) tx, err := db.DB.Begin(ctx)
if err != nil { if err != nil {
log.Printf("Failed to begin transaction: %v", err) log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
@@ -119,13 +113,13 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
var giftCardID string var giftCardID string
if req.Action == "create" { if req.Action == "create" {
err = tx.QueryRow(ctx, ` err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, expiry_date) INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory)
VALUES ($1, $1, $2, FALSE, $3) VALUES ($1, $1, $2, FALSE)
RETURNING id RETURNING id
`, req.Amount, adminID, expiryDate).Scan(&giftCardID) `, req.Amount, adminID).Scan(&giftCardID)
if err != nil { if err != nil {
log.Printf("Failed to create gift card: %v", err) log.Printf("Failed to create gift card: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -135,11 +129,11 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
`, giftCardID, req.Amount, req.UserID) `, giftCardID, req.Amount, req.UserID)
if err != nil { if err != nil {
log.Printf("Failed to create gift_card_transaction: %v", err) log.Printf("Failed to create gift_card_transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
} else { } else {
cardID := normalizeCode(*req.GiftCardID) cardID := validators.NormalizeGiftCardCode(*req.GiftCardID)
var redeemedBy sql.NullString var redeemedBy sql.NullString
err = tx.QueryRow(ctx, "SELECT redeemed_by FROM gift_cards WHERE id = $1", cardID).Scan(&redeemedBy) err = tx.QueryRow(ctx, "SELECT redeemed_by FROM gift_cards WHERE id = $1", cardID).Scan(&redeemedBy)
if err != nil { if err != nil {
@@ -148,7 +142,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("Failed to check gift card: %v", err) log.Printf("Failed to check gift card: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
if redeemedBy.Valid { if redeemedBy.Valid {
@@ -161,7 +155,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
err = tx.QueryRow(ctx, `SELECT is_inventory, total_funds_added FROM gift_cards WHERE id = $1`, cardID).Scan(&isInventory, &previousTotal) err = tx.QueryRow(ctx, `SELECT is_inventory, total_funds_added FROM gift_cards WHERE id = $1`, cardID).Scan(&isInventory, &previousTotal)
if err != nil { if err != nil {
log.Printf("Failed to check gift card state: %v", err) log.Printf("Failed to check gift card state: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -174,7 +168,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
`, req.Amount, cardID) `, req.Amount, cardID)
if err != nil { if err != nil {
log.Printf("Failed to top up gift card: %v", err) log.Printf("Failed to top up gift card: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -191,7 +185,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
`, cardID, transactionType, req.Amount, req.UserID, notes) `, cardID, transactionType, req.Amount, req.UserID, notes)
if err != nil { if err != nil {
log.Printf("Failed to create gift_card_transaction: %v", err) log.Printf("Failed to create gift_card_transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -211,7 +205,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
`, *req.RedeemToUserID, giftCardID) `, *req.RedeemToUserID, giftCardID)
if err != nil { if err != nil {
log.Printf("Failed to redeem gift card to user account: %v", err) log.Printf("Failed to redeem gift card to user account: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -224,7 +218,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
`, *req.RedeemToUserID, req.Amount) `, *req.RedeemToUserID, req.Amount)
if err != nil { if err != nil {
log.Printf("Failed to update user gift card balance: %v", err) log.Printf("Failed to update user gift card balance: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
} }
@@ -253,7 +247,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("Failed to verify saved card: %v", err) log.Printf("Failed to verify saved card: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
} }
@@ -379,7 +373,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
).Scan(&tillSaleID) ).Scan(&tillSaleID)
if err != nil { if err != nil {
log.Printf("Failed to insert till sale: %v", err) log.Printf("Failed to insert till sale: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -389,13 +383,13 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
`, tillSaleID, giftCardID) `, tillSaleID, giftCardID)
if err != nil { if err != nil {
log.Printf("Failed to update gift_card_transactions reference: %v", err) log.Printf("Failed to update gift_card_transactions reference: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
if err := tx.Commit(ctx); err != nil { if err := tx.Commit(ctx); err != nil {
log.Printf("Failed to commit till sale transaction: %v", err) log.Printf("Failed to commit till sale transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -431,7 +425,7 @@ func GetTillCheckoutStatus(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("Failed to find till sale: %v", err) log.Printf("Failed to find till sale: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -465,7 +459,7 @@ func GetTillCheckoutStatus(w http.ResponseWriter, r *http.Request) {
`, paymentResult.SquarePayID, tillSaleID) `, paymentResult.SquarePayID, tillSaleID)
if err != nil { if err != nil {
log.Printf("Failed to update till sale: %v", err) log.Printf("Failed to update till sale: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
+2
View File
@@ -103,6 +103,7 @@ func Migrate(t *testing.T, pool *pgxpool.Pool) {
// Drop all tables, sequences, and views in correct order // Drop all tables, sequences, and views in correct order
dropOrder := []string{ dropOrder := []string{
"till_sales", "till_sales",
"admin_audit_log",
"gift_card_transactions", "gift_card_transactions",
"gift_card_expired_balances", "gift_card_expired_balances",
"booking_discounts", "booking_discounts",
@@ -272,6 +273,7 @@ func TruncateTables(t *testing.T, pool *pgxpool.Pool) {
tables := []string{ tables := []string{
"till_sales", "till_sales",
"admin_audit_log",
"gift_card_transactions", "gift_card_transactions",
"gift_card_expired_balances", "gift_card_expired_balances",
"booking_discounts", "booking_discounts",
+20 -3
View File
@@ -1754,9 +1754,26 @@ CREATE INDEX idx_gift_card_expired_balances_unclaimed ON gift_card_expired_balan
WHERE claimed_at IS NULL; WHERE claimed_at IS NULL;
CREATE INDEX idx_gift_card_transactions_created_at ON gift_card_transactions(created_at); CREATE INDEX idx_gift_card_transactions_created_at ON gift_card_transactions(created_at);
-- Foreign Key Constraints added after all dependent tables are created -- =======================================
-- to avoid creation order dependencies during schema initialization. -- ADMIN AUDIT LOG TABLE
ALTER TABLE payments ADD CONSTRAINT fk_payments_gift_card FOREIGN KEY (gift_card_id) REFERENCES gift_cards(id); -- =======================================
-- Records admin actions that access or modify user financial data.
-- Legal basis: GDPR Article 30 (records of processing) and financial audit requirements.
-- =======================================
CREATE TABLE admin_audit_log (
id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('admin_audit_log'),
admin_id CHAR(12) NOT NULL REFERENCES users(id),
action_type VARCHAR(30) NOT NULL,
target_user_id CHAR(12) REFERENCES users(id),
target_gift_card_id CHAR(12) REFERENCES gift_cards(id),
details JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_admin_audit_log_admin ON admin_audit_log(admin_id);
CREATE INDEX idx_admin_audit_log_target_user ON admin_audit_log(target_user_id);
CREATE INDEX idx_admin_audit_log_created_at ON admin_audit_log(created_at);
-- ======================================= -- =======================================
-- SQUARE DEPOSITS TABLE (Bank Reconciliation) -- SQUARE DEPOSITS TABLE (Bank Reconciliation)