From 30ded0bb9574fd2d4c123e7af3eb2d0eb9013025 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 11 Jun 2026 22:08:18 +0100 Subject: [PATCH] feat(payments): add gift card system v2 with management, expiry, inventory, audit log Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/payments/giftcards.go | 436 ++++++++++++-- backend/handlers/payments/giftcards_test.go | 625 +++++++++++++++++++- backend/handlers/payments/handlers.go | 8 +- backend/handlers/payments/payments_test.go | 4 +- backend/handlers/payments/service.go | 49 +- backend/handlers/payments/till.go | 94 ++- backend/handlers/payments/till_test.go | 332 +++++++++++ 7 files changed, 1479 insertions(+), 69 deletions(-) create mode 100644 backend/handlers/payments/till_test.go diff --git a/backend/handlers/payments/giftcards.go b/backend/handlers/payments/giftcards.go index 9bd25ca..f8e7ba5 100644 --- a/backend/handlers/payments/giftcards.go +++ b/backend/handlers/payments/giftcards.go @@ -6,6 +6,7 @@ import ( "errors" "log" "net/http" + "strconv" "time" "crussell/db" @@ -27,6 +28,8 @@ type GiftCard struct { CreatedAt time.Time `json:"created_at"` RedeemedAt *time.Time `json:"redeemed_at,omitempty"` RedeemedBy *string `json:"redeemed_by,omitempty"` + IsInventory bool `json:"is_inventory"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` } type UserBalance struct { @@ -37,19 +40,26 @@ type UserBalance struct { UpdatedAt time.Time `json:"updated_at"` } -type GiftCardSummary struct { +type GiftCardListResponse struct { TotalUnclaimed float64 `json:"total_unclaimed"` TotalUserBalances float64 `json:"total_user_balances"` GiftCards []GiftCard `json:"gift_cards"` UserBalances []UserBalance `json:"user_balances"` + Total int `json:"total"` + UBTotal int `json:"ub_total"` + Page int `json:"page"` + PerPage int `json:"perPage"` + TotalPages int `json:"totalPages"` } type CreateGiftCardRequest struct { - Amount float64 `json:"amount"` // in pounds (e.g. 10.00, 25.00) + Amount float64 `json:"amount"` + IsInventory bool `json:"is_inventory,omitempty"` } type TopUpGiftCardRequest struct { - Amount float64 `json:"amount"` // in pounds + Amount float64 `json:"amount"` + PaymentMethod string `json:"payment_method"` } type TransferGiftCardRequest struct { @@ -76,14 +86,41 @@ type RedeemGiftCardRequest struct { func GetGiftCards(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - var summary GiftCardSummary - summary.GiftCards = []GiftCard{} + // Parse query parameters + query := r.URL.Query() + searchTerm := query.Get("q") + + // Pagination parameters + page := 1 + perPage := 10 + + if pageStr := query.Get("page"); pageStr != "" { + if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { + page = p + } + } + + if perPageStr := query.Get("per_page"); perPageStr != "" { + if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 { + perPage = pp + } + } + + offset := (page - 1) * perPage + + var resp GiftCardListResponse + resp.GiftCards = []GiftCard{} + resp.UserBalances = []UserBalance{} + resp.Page = page + resp.PerPage = perPage + + // --- Aggregate totals (unfiltered, unpaginated) --- err := db.DB.QueryRow(ctx, ` SELECT COALESCE(SUM(amount_remaining), 0) FROM gift_cards WHERE redeemed_by IS NULL - `).Scan(&summary.TotalUnclaimed) + `).Scan(&resp.TotalUnclaimed) if err != nil { log.Printf("Failed to calculate total unclaimed gift cards: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -93,31 +130,73 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) { err = db.DB.QueryRow(ctx, ` SELECT COALESCE(SUM(balance), 0) FROM user_giftcard_balances - `).Scan(&summary.TotalUserBalances) + `).Scan(&resp.TotalUserBalances) if err != nil { log.Printf("Failed to calculate total user balances: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - rows, err := db.DB.Query(ctx, ` - SELECT id, total_funds_added, amount_remaining, created_by, created_at, redeemed_at, redeemed_by - FROM gift_cards - ORDER BY created_at DESC - `) + // --- Gift cards (paginated, optionally filtered) --- + + var gcTotal int + var gcCountQuery string + var gcListQuery string + var gcCountArgs []interface{} + var gcListArgs []interface{} + + if searchTerm != "" { + searchPattern := "%" + searchTerm + "%" + + gcCountQuery = ` + SELECT COUNT(*) + FROM gift_cards + WHERE id ILIKE $1 + ` + 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} + } 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} + } + + err = db.DB.QueryRow(ctx, gcCountQuery, gcCountArgs...).Scan(&gcTotal) + if err != nil { + log.Printf("Failed to count gift cards: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + gcRows, err := db.DB.Query(ctx, gcListQuery, gcListArgs...) if err != nil { log.Printf("Failed to query gift cards: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - defer rows.Close() + defer gcRows.Close() - for rows.Next() { + for gcRows.Next() { var gc GiftCard var createdBy, redeemedBy sql.NullString - var redeemedAt sql.NullTime + var redeemedAt, lastUsedAt sql.NullTime - err = rows.Scan( + err = gcRows.Scan( &gc.ID, &gc.TotalFundsAdded, &gc.AmountRemaining, @@ -125,6 +204,8 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) { &gc.CreatedAt, &redeemedAt, &redeemedBy, + &gc.IsInventory, + &lastUsedAt, ) if err != nil { log.Printf("Failed to scan gift card: %v", err) @@ -141,17 +222,45 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) { if redeemedAt.Valid { gc.RedeemedAt = &redeemedAt.Time } + if lastUsedAt.Valid { + gc.LastUsedAt = &lastUsedAt.Time + } - summary.GiftCards = append(summary.GiftCards, gc) + resp.GiftCards = append(resp.GiftCards, gc) } - summary.UserBalances = []UserBalance{} - ubRows, err := db.DB.Query(ctx, ` - SELECT b.user_id, u.n_first_name || ' ' || u.n_last_name AS name, u.email, b.balance, b.updated_at - FROM user_giftcard_balances b - JOIN users u ON b.user_id = u.id - ORDER BY b.updated_at DESC - `) + // --- User balances (all, optionally filtered) --- + + var ubTotal int + var ubListQuery string + var ubListArgs []interface{} + + if searchTerm != "" { + searchPattern := "%" + searchTerm + "%" + + ubListQuery = ` + SELECT COUNT(*) OVER() AS total_count, + b.user_id, u.n_first_name || ' ' || u.n_last_name AS name, u.email, b.balance, b.updated_at + FROM user_giftcard_balances b + JOIN users u ON b.user_id = u.id + WHERE u.n_first_name ILIKE $1 + OR u.n_last_name ILIKE $1 + OR u.email ILIKE $1 + ORDER BY b.updated_at DESC + ` + ubListArgs = []interface{}{searchPattern} + } else { + ubListQuery = ` + SELECT COUNT(*) OVER() AS total_count, + b.user_id, u.n_first_name || ' ' || u.n_last_name AS name, u.email, b.balance, b.updated_at + FROM user_giftcard_balances b + JOIN users u ON b.user_id = u.id + ORDER BY b.updated_at DESC + ` + ubListArgs = []interface{}{} + } + + ubRows, err := db.DB.Query(ctx, ubListQuery, ubListArgs...) if err != nil { log.Printf("Failed to query user balances: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -161,7 +270,9 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) { for ubRows.Next() { var ub UserBalance + var rowTotal int err = ubRows.Scan( + &rowTotal, &ub.UserID, &ub.Name, &ub.Email, @@ -173,11 +284,23 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal server error", http.StatusInternalServerError) return } - summary.UserBalances = append(summary.UserBalances, ub) + if ubTotal == 0 { + ubTotal = rowTotal + } + resp.UserBalances = append(resp.UserBalances, ub) } + resp.UBTotal = ubTotal + resp.Total = gcTotal + + totalPages := (gcTotal + perPage - 1) / perPage + if totalPages == 0 { + totalPages = 1 + } + resp.TotalPages = totalPages + w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(summary) + json.NewEncoder(w).Encode(resp) } func CreateGiftCard(w http.ResponseWriter, r *http.Request) { @@ -190,7 +313,11 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) { return } - if req.Amount <= 0 { + if req.Amount < 0 { + http.Error(w, "Amount must not be negative", http.StatusBadRequest) + return + } + if req.Amount == 0 && !req.IsInventory { http.Error(w, "Amount must be greater than zero", http.StatusBadRequest) return } @@ -204,16 +331,19 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) { defer tx.Rollback(ctx) var gc GiftCard + var lastUsedAt sql.NullTime err = tx.QueryRow(ctx, ` - INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by) - VALUES ($1, $1, $2) - RETURNING id, total_funds_added, amount_remaining, created_by, created_at - `, req.Amount, adminID).Scan( + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at) + VALUES ($1, $1, $2, $3, NOW()) + RETURNING id, total_funds_added, amount_remaining, created_by, created_at, is_inventory, last_used_at + `, req.Amount, adminID, req.IsInventory).Scan( &gc.ID, &gc.TotalFundsAdded, &gc.AmountRemaining, &gc.CreatedBy, &gc.CreatedAt, + &gc.IsInventory, + &lastUsedAt, ) if err != nil { log.Printf("Failed to create gift card: %v", err) @@ -221,13 +351,24 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) { return } - // Create an 'on_the_house' payment record for financial tracking + if lastUsedAt.Valid { + gc.LastUsedAt = &lastUsedAt.Time + } + + var notes *string + if req.IsInventory { + s := "inventory card" + notes = &s + } else { + s := "giveaway" + notes = &s + } _, err = tx.Exec(ctx, ` - INSERT INTO payments (payment_type, payment_method, status, amount, created_by, created_at, updated_at) - VALUES ('full', 'on_the_house', 'completed', $1, $2, NOW(), NOW()) - `, req.Amount, adminID) + INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) + VALUES ($1, 'purchase', $2, 'api', NULL, $3, $4) + `, gc.ID, req.Amount, adminID, notes) if err != nil { - log.Printf("Failed to create payment record for gift card: %v", err) + log.Printf("Failed to record gift card transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } @@ -245,6 +386,7 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) { func TopUpGiftCard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + adminID, _ := ctx.Value(mw.UserIDKey).(string) cardID := chi.URLParam(r, "id") if cardID == "" || !validators.IsValidID(cardID) { http.Error(w, "Invalid gift card ID", http.StatusBadRequest) @@ -262,6 +404,18 @@ func TopUpGiftCard(w http.ResponseWriter, r *http.Request) { return } + validMethods := map[string]string{ + "cash": "cash", + "card_machine": "in_person_card", + "online_square": "online_square", + "on_the_house": "on_the_house", + } + _, ok := validMethods[req.PaymentMethod] + if !ok { + http.Error(w, "Invalid payment method. Must be one of: cash, card_machine, online_square, on_the_house", http.StatusBadRequest) + return + } + tx, err := db.DB.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction: %v", err) @@ -271,7 +425,9 @@ func TopUpGiftCard(w http.ResponseWriter, r *http.Request) { defer tx.Rollback(ctx) var redeemedBy sql.NullString - err = tx.QueryRow(ctx, "SELECT redeemed_by FROM gift_cards WHERE id = $1", cardID).Scan(&redeemedBy) + var isInventory bool + var currentTotalFunds float64 + err = tx.QueryRow(ctx, "SELECT redeemed_by, is_inventory, total_funds_added FROM gift_cards WHERE id = $1", cardID).Scan(&redeemedBy, &isInventory, ¤tTotalFunds) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Gift card not found", http.StatusNotFound) @@ -287,12 +443,21 @@ func TopUpGiftCard(w http.ResponseWriter, r *http.Request) { return } + txType := "topup" + var notes *string + if isInventory && currentTotalFunds == 0 { + txType = "purchase" + s := "first top-up on inventory card" + notes = &s + } + var gc GiftCard var createdBy sql.NullString err = tx.QueryRow(ctx, ` UPDATE gift_cards SET total_funds_added = total_funds_added + $1, - amount_remaining = amount_remaining + $1 + amount_remaining = amount_remaining + $1, + last_used_at = NOW() WHERE id = $2 RETURNING id, total_funds_added, amount_remaining, created_by, created_at `, req.Amount, cardID).Scan( @@ -312,6 +477,16 @@ func TopUpGiftCard(w http.ResponseWriter, r *http.Request) { gc.CreatedBy = &createdBy.String } + _, err = tx.Exec(ctx, ` + INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) + VALUES ($1, $2, $3, 'api', NULL, $4, $5) + `, cardID, txType, req.Amount, adminID, notes) + if err != nil { + log.Printf("Failed to record gift card transaction: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -397,7 +572,8 @@ func TransferGiftCard(w http.ResponseWriter, r *http.Request) { _, err = tx.Exec(ctx, ` UPDATE gift_cards - SET amount_remaining = amount_remaining - $1 + SET amount_remaining = amount_remaining - $1, + last_used_at = NOW() WHERE id = $2 `, req.Amount, fromCardID) if err != nil { @@ -409,7 +585,8 @@ func TransferGiftCard(w http.ResponseWriter, r *http.Request) { _, err = tx.Exec(ctx, ` UPDATE gift_cards SET amount_remaining = amount_remaining + $1, - total_funds_added = total_funds_added + $1 + total_funds_added = total_funds_added + $1, + last_used_at = NOW() WHERE id = $2 `, req.Amount, req.ToCardID) if err != nil { @@ -511,6 +688,16 @@ func RedeemGiftCard(w http.ResponseWriter, r *http.Request) { return } + _, err = tx.Exec(ctx, ` + INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) + VALUES ($1, 'redeem_to_balance', $2, 'api', NULL, $3, NULL) + `, code, amountRemaining, userID) + if err != nil { + log.Printf("Failed to record gift card transaction: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -607,6 +794,18 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { paymentService := NewPaymentService() + if req.IdempotencyKey != "" { + existing, err := paymentService.CheckIdempotencyByKey(ctx, req.IdempotencyKey) + if err != nil { + log.Printf("Failed to check idempotency: %v", err) + } + if existing != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(existing) + return + } + } + var sourceID string var savedCardID *string @@ -671,11 +870,12 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { var cardID string if req.RecipientType == "self" { + expiryDate := time.Now().AddDate(1, 0, 0) err = tx.QueryRow(ctx, ` - INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by) - VALUES ($1, 0, $2, NOW(), $2) + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by, is_inventory, expiry_date) + VALUES ($1, 0, $2, NOW(), $2, FALSE, $3) RETURNING id - `, amountPounds, userID).Scan(&cardID) + `, amountPounds, userID, expiryDate).Scan(&cardID) if err != nil { log.Printf("Failed to insert gift card: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -694,12 +894,23 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal server error", http.StatusInternalServerError) return } + + _, err = tx.Exec(ctx, ` + INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) + VALUES ($1, 'purchase', $2, 'api', NULL, $3, 'self-purchase, auto-redeemed') + `, cardID, amountPounds, userID) + if err != nil { + log.Printf("Failed to record gift card transaction: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } } else { + expiryDate := time.Now().AddDate(1, 0, 0) err = tx.QueryRow(ctx, ` - INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by) - VALUES ($1, $1, $2) + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, expiry_date) + VALUES ($1, $1, $2, FALSE, $3) RETURNING id - `, amountPounds, userID).Scan(&cardID) + `, amountPounds, userID, expiryDate).Scan(&cardID) if err != nil { log.Printf("Failed to insert gift card: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -713,6 +924,16 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { recipient = userEmail } log.Printf("[TODO EMAIL] Send gift card code %s (Value: £%.2f) to %s", cardID, amountPounds, recipient) + + _, err = tx.Exec(ctx, ` + INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) + VALUES ($1, 'purchase', $2, 'api', NULL, $3, 'purchased for friend') + `, cardID, amountPounds, userID) + if err != nil { + log.Printf("Failed to record gift card transaction: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } } fees := paymentService.CalculateFees(req.Amount, "online") @@ -766,3 +987,126 @@ func normalizeCode(code string) string { } return clean } + +type ExpiredBalance struct { + ID string `json:"id"` + AccountID *string `json:"account_id,omitempty"` + OriginalBalance float64 `json:"original_balance"` + ExpiredAt time.Time `json:"expired_at"` + ClaimedAt *time.Time `json:"claimed_at,omitempty"` + ClaimedByAdmin *string `json:"claimed_by_admin,omitempty"` + Notes *string `json:"notes,omitempty"` +} + +func GetExpiredBalances(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + rows, err := db.DB.Query(ctx, ` + SELECT id, account_id, original_balance, expired_at, claimed_at, claimed_by_admin, notes + FROM gift_card_expired_balances + ORDER BY expired_at DESC + `) + if err != nil { + log.Printf("Failed to query expired balances: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var balances []ExpiredBalance + for rows.Next() { + var b ExpiredBalance + var accountID, claimedByAdmin, notes sql.NullString + var claimedAt sql.NullTime + + err = rows.Scan(&b.ID, &accountID, &b.OriginalBalance, &b.ExpiredAt, &claimedAt, &claimedByAdmin, ¬es) + if err != nil { + log.Printf("Failed to scan expired balance: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + if accountID.Valid { + b.AccountID = &accountID.String + } + if claimedAt.Valid { + b.ClaimedAt = &claimedAt.Time + } + if claimedByAdmin.Valid { + b.ClaimedByAdmin = &claimedByAdmin.String + } + if notes.Valid { + b.Notes = ¬es.String + } + + balances = append(balances, b) + } + + if balances == nil { + balances = []ExpiredBalance{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "expired_balances": balances, + "total": len(balances), + }) +} + +type ClaimExpiredBalanceRequest struct { + BalanceID string `json:"balance_id"` + Notes *string `json:"notes,omitempty"` +} + +func ClaimExpiredBalance(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + adminID, ok := ctx.Value(mw.UserIDKey).(string) + if !ok || adminID == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req ClaimExpiredBalanceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.BalanceID == "" { + http.Error(w, "balance_id is required", http.StatusBadRequest) + return + } + + var existingClaimedAt sql.NullTime + err := db.DB.QueryRow(ctx, ` + SELECT claimed_at FROM gift_card_expired_balances WHERE id = $1 + `, req.BalanceID).Scan(&existingClaimedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.Error(w, "Expired balance not found", http.StatusNotFound) + return + } + log.Printf("Failed to check expired balance: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + if existingClaimedAt.Valid { + http.Error(w, "Balance already claimed", http.StatusConflict) + return + } + + _, err = db.DB.Exec(ctx, ` + UPDATE gift_card_expired_balances + SET claimed_at = NOW(), claimed_by_admin = $1, notes = $2 + WHERE id = $3 + `, adminID, req.Notes, req.BalanceID) + if err != nil { + log.Printf("Failed to claim expired balance: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "claimed"}) +} diff --git a/backend/handlers/payments/giftcards_test.go b/backend/handlers/payments/giftcards_test.go index 3f4cfcd..0260960 100644 --- a/backend/handlers/payments/giftcards_test.go +++ b/backend/handlers/payments/giftcards_test.go @@ -87,7 +87,10 @@ func TestAdminTopUpGiftCard(t *testing.T) { t.Fatalf("failed to insert gift card: %v", err) } - reqBody, _ := json.Marshal(map[string]interface{}{"amount": 25.00}) + reqBody, _ := json.Marshal(map[string]interface{}{ + "amount": 25.00, + "payment_method": "on_the_house", + }) req := httptest.NewRequest("PUT", "/api/admin/gift-cards/"+cardID+"/topup", bytes.NewBuffer(reqBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") @@ -498,3 +501,623 @@ func TestAdminRecordPayment_CashAndGiftCard(t *testing.T) { t.Errorf("expected user balance to be 35.00, got %.2f", userBalance) } } + +// --- New Tests for branch features --- + +func TestGetExpiredBalances(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") + + // Seed expired balances + for i := 0; i < 2; i++ { + _, err = db.DB.Exec(ctx, ` + INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at) + VALUES ($1, $2, NOW() - interval '30 days') + `, adminID, float64(25.00*(i+1))) + if err != nil { + t.Fatalf("failed to seed expired balance %d: %v", i, err) + } + } + + req := httptest.NewRequest("GET", "/api/admin/gift-cards/expired-balances", nil) + req.Header.Set("Authorization", "Bearer "+token) + + w := httptest.NewRecorder() + r := chi.NewRouter() + r.Use(mw.RequireAuth) + r.Get("/api/admin/gift-cards/expired-balances", GetExpiredBalances) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + total, ok := resp["total"].(float64) + if !ok || total != 2 { + t.Errorf("expected total 2, got %v", resp["total"]) + } + + balances, ok := resp["expired_balances"].([]interface{}) + if !ok || len(balances) != 2 { + t.Errorf("expected 2 expired_balances, got %d", len(balances)) + } +} + +func TestClaimExpiredBalance_HappyPath(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") + + // Seed an expired balance with known ID + var balanceID string + err = db.DB.QueryRow(ctx, ` + INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at) + VALUES ($1, 50.00, NOW() - interval '30 days') + RETURNING id + `, adminID).Scan(&balanceID) + if err != nil { + t.Fatalf("failed to seed expired balance: %v", err) + } + + notes := "claimed via test" + reqBody, _ := json.Marshal(map[string]interface{}{ + "balance_id": balanceID, + "notes": notes, + }) + req := httptest.NewRequest("POST", "/api/admin/gift-cards/expired-balances/claim", 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/expired-balances/claim", ClaimExpiredBalance) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp["status"] != "claimed" { + t.Errorf("expected status 'claimed', got '%s'", resp["status"]) + } + + // Verify claimed_at is set in DB + var claimedAt sql.NullTime + err = db.DB.QueryRow(ctx, "SELECT claimed_at FROM gift_card_expired_balances WHERE id = $1", balanceID).Scan(&claimedAt) + if err != nil { + t.Fatalf("failed to query expired balance: %v", err) + } + if !claimedAt.Valid { + t.Error("expected claimed_at to be set, got null") + } +} + +func TestClaimExpiredBalance_AlreadyClaimed(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") + + // Seed an expired balance that is already claimed + var balanceID string + err = db.DB.QueryRow(ctx, ` + INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at, claimed_at, claimed_by_admin) + VALUES ($1, 50.00, NOW() - interval '30 days', NOW(), $2) + RETURNING id + `, adminID, adminID).Scan(&balanceID) + if err != nil { + t.Fatalf("failed to seed claimed expired balance: %v", err) + } + + reqBody, _ := json.Marshal(map[string]interface{}{ + "balance_id": balanceID, + }) + req := httptest.NewRequest("POST", "/api/admin/gift-cards/expired-balances/claim", 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/expired-balances/claim", ClaimExpiredBalance) + r.ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Errorf("expected 409, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestClaimExpiredBalance_NotFound(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{}{ + "balance_id": "aaaaaaaaaaaa", + }) + req := httptest.NewRequest("POST", "/api/admin/gift-cards/expired-balances/claim", 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/expired-balances/claim", ClaimExpiredBalance) + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestGetGiftCards_Pagination(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") + + // Create 15 gift cards + for i := 0; i < 15; i++ { + _, err = db.DB.Exec(ctx, ` + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by) + VALUES (10.00, 10.00, $1) + `, adminID) + if err != nil { + t.Fatalf("failed to create gift card %d: %v", i, err) + } + } + + // Request page 1 with per_page=5 + req := httptest.NewRequest("GET", "/api/admin/gift-cards?page=1&per_page=5", nil) + req.Header.Set("Authorization", "Bearer "+token) + + w := httptest.NewRecorder() + r := chi.NewRouter() + r.Use(mw.RequireAuth) + r.Get("/api/admin/gift-cards", GetGiftCards) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + var resp GiftCardListResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(resp.GiftCards) != 5 { + t.Errorf("expected 5 gift cards on page 1, got %d", len(resp.GiftCards)) + } + if resp.Total != 15 { + t.Errorf("expected total 15, got %d", resp.Total) + } + if resp.Page != 1 { + t.Errorf("expected page 1, got %d", resp.Page) + } + if resp.PerPage != 5 { + t.Errorf("expected perPage 5, got %d", resp.PerPage) + } + if resp.TotalPages != 3 { + t.Errorf("expected totalPages 3, got %d", resp.TotalPages) + } + + // Request page 3 to verify last page + req3 := httptest.NewRequest("GET", "/api/admin/gift-cards?page=3&per_page=5", nil) + req3.Header.Set("Authorization", "Bearer "+token) + + w3 := httptest.NewRecorder() + r3 := chi.NewRouter() + r3.Use(mw.RequireAuth) + r3.Get("/api/admin/gift-cards", GetGiftCards) + r3.ServeHTTP(w3, req3) + + if w3.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w3.Code) + } + + var resp3 GiftCardListResponse + if err := json.NewDecoder(w3.Body).Decode(&resp3); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(resp3.GiftCards) != 5 { + t.Errorf("expected 5 gift cards on page 3, got %d", len(resp3.GiftCards)) + } + if resp3.Total != 15 { + t.Errorf("expected total 15 on page 3, got %d", resp3.Total) + } +} + +func TestGetGiftCards_Search(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") + + // Create cards with specific hex IDs for search predictability + searchableID := "aaaaaabbbbcc" + nonSearchableID := "ddddeeeeffff" + + _, err = db.DB.Exec(ctx, ` + INSERT INTO gift_cards (id, total_funds_added, amount_remaining, created_by) + VALUES ($1, 10.00, 10.00, $2) + `, searchableID, adminID) + if err != nil { + t.Fatalf("failed to create searchable card: %v", err) + } + + _, err = db.DB.Exec(ctx, ` + INSERT INTO gift_cards (id, total_funds_added, amount_remaining, created_by) + VALUES ($1, 20.00, 20.00, $2) + `, nonSearchableID, adminID) + if err != nil { + t.Fatalf("failed to create non-searchable card: %v", err) + } + + // Search by partial ID match + req := httptest.NewRequest("GET", "/api/admin/gift-cards?q=aaaa", nil) + req.Header.Set("Authorization", "Bearer "+token) + + w := httptest.NewRecorder() + r := chi.NewRouter() + r.Use(mw.RequireAuth) + r.Get("/api/admin/gift-cards", GetGiftCards) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + var resp GiftCardListResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp.Total != 1 { + t.Errorf("expected total 1, got %d", resp.Total) + } + if len(resp.GiftCards) != 1 { + t.Errorf("expected 1 gift card in results, got %d", len(resp.GiftCards)) + } + if len(resp.GiftCards) > 0 && resp.GiftCards[0].ID != searchableID { + t.Errorf("expected card ID %s, got %s", searchableID, resp.GiftCards[0].ID) + } +} + +func TestCreateGiftCard_NegativeAmount(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": -10.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.StatusBadRequest { + t.Errorf("expected 400 for negative amount, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestCreateGiftCard_ZeroAmountNoInventory(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": 0, "is_inventory": false}) + 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.StatusBadRequest { + t.Errorf("expected 400 for zero amount without inventory, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestCreateGiftCard_ZeroAmountInventory(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": 0, "is_inventory": true}) + 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.Errorf("expected 201 for inventory card with zero amount, got %d. Body: %s", w.Code, w.Body.String()) + } + + var gc GiftCard + if err := json.NewDecoder(w.Body).Decode(&gc); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if !gc.IsInventory { + t.Error("expected card to be inventory card") + } + if gc.TotalFundsAdded != 0 || gc.AmountRemaining != 0 { + t.Errorf("expected zero balance card, got added=%.2f remaining=%.2f", gc.TotalFundsAdded, gc.AmountRemaining) + } +} + +func TestTopUpGiftCard_InvalidPaymentMethod(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") + + // Create gift card + var cardID string + err = db.DB.QueryRow(ctx, ` + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by) + VALUES (50.00, 50.00, $1) + RETURNING id + `, adminID).Scan(&cardID) + if err != nil { + t.Fatalf("failed to insert gift card: %v", err) + } + + reqBody, _ := json.Marshal(map[string]interface{}{ + "amount": 25.00, + "payment_method": "invalid_method", + }) + req := httptest.NewRequest("PUT", "/api/admin/gift-cards/"+cardID+"/topup", 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.Put("/api/admin/gift-cards/{id}/topup", TopUpGiftCard) + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid payment method, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestTopUpGiftCard_RedeemedCard(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") + + // Create a card that's already redeemed + var cardID string + err = db.DB.QueryRow(ctx, ` + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by) + VALUES (50.00, 0, $1, NOW(), $1) + RETURNING id + `, adminID).Scan(&cardID) + if err != nil { + t.Fatalf("failed to insert redeemed gift card: %v", err) + } + + reqBody, _ := json.Marshal(map[string]interface{}{ + "amount": 25.00, + "payment_method": "on_the_house", + }) + req := httptest.NewRequest("PUT", "/api/admin/gift-cards/"+cardID+"/topup", 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.Put("/api/admin/gift-cards/{id}/topup", TopUpGiftCard) + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for redeemed card topup, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestBuyGiftCard_Idempotency(t *testing.T) { + resetGiftCardsTestData(t) + + ctx := context.Background() + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + token := jwt.GenerateTestToken(userID, "verified_email") + + idempotencyKey := "idempotent-buy-gc-test" + + reqBody := map[string]interface{}{ + "amount": 2000, + "recipient_type": "self", + "new_card_token": "cnon:card-nonce-ok", + "idempotency_key": idempotencyKey, + } + + // Send request + body1, _ := json.Marshal(reqBody) + req1 := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(body1)) + req1.Header.Set("Authorization", "Bearer "+token) + req1.Header.Set("Content-Type", "application/json") + + w1 := httptest.NewRecorder() + r1 := chi.NewRouter() + r1.Use(mw.RequireAuth) + r1.Post("/api/user/giftcards/buy", BuyGiftCard) + r1.ServeHTTP(w1, req1) + + if w1.Code != http.StatusCreated && w1.Code != http.StatusOK { + t.Errorf("buy request: expected 201 or 200, got %d. Body: %s", w1.Code, w1.Body.String()) + } + + // Verify exactly one payment record was created for this key + var payCount int + err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE idempotency_key = $1", idempotencyKey).Scan(&payCount) + if err != nil { + t.Fatalf("failed to query payments: %v", err) + } + if payCount != 1 { + t.Errorf("expected 1 payment record for idempotency key, got %d", payCount) + } + + // Verify exactly one user balance record + var balCount int + err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balCount) + if err != nil { + t.Fatalf("failed to query user balances: %v", err) + } + if balCount != 1 { + t.Errorf("expected 1 user balance record, got %d", balCount) + } + + // Verify exactly one gift card was created for self-purchase (amount_remaining=0, redeemed) + var cardCount int + err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE redeemed_by = $1", userID).Scan(&cardCount) + if err != nil { + t.Fatalf("failed to query gift cards: %v", err) + } + if cardCount != 1 { + t.Errorf("expected 1 gift card record, got %d", cardCount) + } + + // Send second request with same idempotency key + body2, _ := json.Marshal(reqBody) + req2 := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(body2)) + req2.Header.Set("Authorization", "Bearer "+token) + req2.Header.Set("Content-Type", "application/json") + + w2 := httptest.NewRecorder() + r2 := chi.NewRouter() + r2.Use(mw.RequireAuth) + r2.Post("/api/user/giftcards/buy", BuyGiftCard) + r2.ServeHTTP(w2, req2) + + // Verify counts remain unchanged (idempotent) + var payCount2 int + err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE idempotency_key = $1", idempotencyKey).Scan(&payCount2) + if err != nil { + t.Fatalf("failed to query payments: %v", err) + } + if payCount2 != 1 { + t.Errorf("after second request: expected 1 payment record, got %d", payCount2) + } + + var balCount2 int + err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balCount2) + if err != nil { + t.Fatalf("failed to query user balances: %v", err) + } + if balCount2 != 1 { + t.Errorf("after second request: expected 1 user balance record, got %d", balCount2) + } + + var cardCount2 int + err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE redeemed_by = $1", userID).Scan(&cardCount2) + if err != nil { + t.Fatalf("failed to query gift cards: %v", err) + } + if cardCount2 != 1 { + t.Errorf("after second request: expected 1 gift card record, got %d", cardCount2) + } +} diff --git a/backend/handlers/payments/handlers.go b/backend/handlers/payments/handlers.go index a6e7b0a..462cece 100644 --- a/backend/handlers/payments/handlers.go +++ b/backend/handlers/payments/handlers.go @@ -248,7 +248,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { } // Deduct directly from card remaining amount - _, err = tx.Exec(r.Context(), "UPDATE gift_cards SET amount_remaining = amount_remaining - $1 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 { log.Printf("Failed to deduct gift card amount: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -366,7 +366,7 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) { UpdatedAt: time.Now(), } - paymentID, err := service.CreatePaymentRecord(r.Context(), record) + paymentID, err := service.CreatePaymentRecord(r.Context(), record, nil) if err != nil { log.Printf("Failed to create payment record: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -540,7 +540,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { CreatedBy: &userID, } - paymentID, err := service.CreatePaymentRecord(r.Context(), record) + paymentID, err := service.CreatePaymentRecord(r.Context(), record, nil) if err != nil { log.Printf("Failed to create payment record: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -879,7 +879,7 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) { CreatedBy: &userID, } - paymentID, err := service.CreatePaymentRecord(r.Context(), record) + paymentID, err := service.CreatePaymentRecord(r.Context(), record, nil) if err != nil { log.Printf("Failed to create payment record: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) diff --git a/backend/handlers/payments/payments_test.go b/backend/handlers/payments/payments_test.go index 8bfdd7a..2f77b18 100644 --- a/backend/handlers/payments/payments_test.go +++ b/backend/handlers/payments/payments_test.go @@ -1407,7 +1407,7 @@ func TestGetBookingRemainingBalanceCents(t *testing.T) { PaymentMethod: "cash", Status: "completed", Amount: 20.00, - }) + }, nil) if err != nil { t.Fatalf("failed to create payment: %v", err) } @@ -1426,7 +1426,7 @@ func TestGetBookingRemainingBalanceCents(t *testing.T) { PaymentMethod: "cash", Status: "completed", Amount: float64(afterPartial) / 100.0, - }) + }, nil) if err != nil { t.Fatalf("failed to create payment: %v", err) } diff --git a/backend/handlers/payments/service.go b/backend/handlers/payments/service.go index c2fa222..7b6a769 100644 --- a/backend/handlers/payments/service.go +++ b/backend/handlers/payments/service.go @@ -51,6 +51,7 @@ type PaymentRecord struct { CreatedAt time.Time UpdatedAt time.Time CreatedBy *string + GiftCardID *string } type RefundRecord struct { @@ -81,7 +82,7 @@ func (s *PaymentService) CalculateFees(amount int64, method string) float64 { return float64(amount * 175 / 10000) / 100.0 } -func (s *PaymentService) CreatePaymentRecord(ctx context.Context, record PaymentRecord) (string, error) { +func (s *PaymentService) CreatePaymentRecord(ctx context.Context, record PaymentRecord, giftCardID *string) (string, error) { var bookingID *string if record.BookingID != "" { bookingID = &record.BookingID @@ -92,8 +93,9 @@ func (s *PaymentService) CreatePaymentRecord(ctx context.Context, record Payment INSERT INTO payments ( booking_id, payment_type, payment_method, vendor_code, invoice_number, status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount, - user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by, + gift_card_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) RETURNING id `, bookingID, @@ -114,6 +116,7 @@ func (s *PaymentService) CreatePaymentRecord(ctx context.Context, record Payment record.CreatedAt, record.UpdatedAt, record.CreatedBy, + giftCardID, ).Scan(&id) if err != nil { @@ -170,7 +173,8 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID rows, err := db.DB.Query(ctx, ` SELECT p.id, p.booking_id, p.payment_type, p.payment_method, p.vendor_code, p.invoice_number, p.status, p.amount, COALESCE(usc.last_4, ''), p.is_vat_applicable, p.vat_rate, p.vat_amount, p.net_amount, - p.user_saved_card_id, p.square_payment_id, p.idempotency_key, p.fees, p.created_at, p.updated_at, p.created_by + p.user_saved_card_id, p.square_payment_id, p.idempotency_key, p.fees, p.created_at, p.updated_at, p.created_by, + p.gift_card_id FROM payments p LEFT JOIN user_saved_cards usc ON p.user_saved_card_id = usc.id WHERE p.booking_id = $1 @@ -189,7 +193,7 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID &p.ID, &p.BookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber, &p.Status, &p.Amount, &p.CardLast4, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount, &p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees, - &p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, + &p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &p.GiftCardID, ) if err != nil { return nil, err @@ -237,14 +241,40 @@ func (s *PaymentService) CheckIdempotency(ctx context.Context, bookingID, idempo err := db.DB.QueryRow(ctx, ` SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number, status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount, - user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by + user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by, + gift_card_id FROM payments WHERE booking_id = $1 AND idempotency_key = $2 `, bookingID, idempotencyKey).Scan( &p.ID, &p.BookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber, &p.Status, &p.Amount, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount, &p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees, - &p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, + &p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &p.GiftCardID, + ) + + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &p, nil +} + +func (s *PaymentService) CheckIdempotencyByKey(ctx context.Context, idempotencyKey string) (*PaymentRecord, error) { + var p PaymentRecord + err := db.DB.QueryRow(ctx, ` + SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number, + status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount, + user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by, + gift_card_id + FROM payments + WHERE idempotency_key = $1 + `, idempotencyKey).Scan( + &p.ID, &p.BookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber, + &p.Status, &p.Amount, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount, + &p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees, + &p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &p.GiftCardID, ) if err != nil { @@ -261,14 +291,15 @@ func (s *PaymentService) GetPaymentByID(ctx context.Context, paymentID string) ( err := db.DB.QueryRow(ctx, ` SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number, status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount, - user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by + user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by, + gift_card_id FROM payments WHERE id = $1 `, paymentID).Scan( &p.ID, &p.BookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber, &p.Status, &p.Amount, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount, &p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees, - &p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, + &p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &p.GiftCardID, ) if err != nil { diff --git a/backend/handlers/payments/till.go b/backend/handlers/payments/till.go index b6beeaa..a2a8027 100644 --- a/backend/handlers/payments/till.go +++ b/backend/handlers/payments/till.go @@ -64,8 +64,8 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { http.Error(w, "Amount must be greater than zero", http.StatusBadRequest) return } - if req.PaymentMethod != "cash" && req.PaymentMethod != "card_machine" && req.PaymentMethod != "saved_card" && req.PaymentMethod != "online_square" { - http.Error(w, "Payment method must be 'cash', 'card_machine', 'saved_card', or 'online_square'", http.StatusBadRequest) + if req.PaymentMethod != "cash" && req.PaymentMethod != "card_machine" && req.PaymentMethod != "saved_card" && req.PaymentMethod != "online_square" && req.PaymentMethod != "on_the_house" { + http.Error(w, "Payment method must be 'cash', 'card_machine', 'saved_card', 'online_square', or 'on_the_house'", http.StatusBadRequest) return } if req.PaymentMethod == "saved_card" && (req.UserSavedCardID == nil || *req.UserSavedCardID == "") { @@ -81,6 +81,31 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { return } + // Idempotency check: if key provided, return existing sale if found + if req.IdempotencyKey != "" { + var existingID string + err := db.DB.QueryRow(ctx, `SELECT id FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&existingID) + if err == nil { + // Existing sale found — return it (idempotent) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(TillSaleResponse{ + ID: existingID, + ItemType: req.ItemType, + TotalAmount: req.Amount, + PaymentMethod: req.PaymentMethod, + Status: "completed", + }) + return + } + } + + 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() tx, err := db.DB.Begin(ctx) @@ -94,15 +119,25 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { var giftCardID string if req.Action == "create" { err = tx.QueryRow(ctx, ` - INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by) - VALUES ($1, $1, $2) + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, expiry_date) + VALUES ($1, $1, $2, FALSE, $3) RETURNING id - `, req.Amount, adminID).Scan(&giftCardID) + `, req.Amount, adminID, expiryDate).Scan(&giftCardID) if err != nil { log.Printf("Failed to create gift card: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } + + _, err = tx.Exec(ctx, ` + INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) + VALUES ($1, 'purchase', $2, 'till_sale', NULL, $3, NULL) + `, giftCardID, req.Amount, req.UserID) + if err != nil { + log.Printf("Failed to create gift_card_transaction: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } } else { cardID := normalizeCode(*req.GiftCardID) var redeemedBy sql.NullString @@ -121,10 +156,20 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { return } + var isInventory bool + var previousTotal float64 + err = tx.QueryRow(ctx, `SELECT is_inventory, total_funds_added FROM gift_cards WHERE id = $1`, cardID).Scan(&isInventory, &previousTotal) + if err != nil { + log.Printf("Failed to check gift card state: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + _, err = tx.Exec(ctx, ` UPDATE gift_cards SET total_funds_added = total_funds_added + $1, - amount_remaining = amount_remaining + $1 + amount_remaining = amount_remaining + $1, + last_used_at = NOW() WHERE id = $2 `, req.Amount, cardID) if err != nil { @@ -132,6 +177,24 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal server error", http.StatusInternalServerError) return } + + transactionType := "topup" + var notes *string + if isInventory && previousTotal == 0 { + transactionType = "purchase" + n := "first top-up on inventory card" + notes = &n + } + _, err = tx.Exec(ctx, ` + INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) + VALUES ($1, $2, $3, 'till_sale', NULL, $4, $5) + `, cardID, transactionType, req.Amount, req.UserID, notes) + if err != nil { + log.Printf("Failed to create gift_card_transaction: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + giftCardID = cardID } @@ -142,7 +205,8 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { UPDATE gift_cards SET amount_remaining = 0, redeemed_at = NOW(), - redeemed_by = $1 + redeemed_by = $1, + last_used_at = NOW() WHERE id = $2 `, *req.RedeemToUserID, giftCardID) if err != nil { @@ -280,6 +344,12 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { squarePaymentID = &paymentResult.SquarePayID saleStatus = "completed" + case "on_the_house": + saleStatus = "completed" + dbPaymentMethod = "on_the_house" + if req.IdempotencyKey == "" { + req.IdempotencyKey = "till-on-the-house-" + giftCardID + "-" + time.Now().Format("20060102150405.000000") + } } desc := fmt.Sprintf("Gift Card %s (£%.2f)", req.Action, req.Amount) @@ -313,6 +383,16 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { return } + _, err = tx.Exec(ctx, ` + UPDATE gift_card_transactions SET reference_id = $1 + WHERE gift_card_id = $2 AND reference_id IS NULL AND created_at > NOW() - INTERVAL '5 seconds' + `, tillSaleID, giftCardID) + if err != nil { + log.Printf("Failed to update gift_card_transactions reference: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit till sale transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) diff --git a/backend/handlers/payments/till_test.go b/backend/handlers/payments/till_test.go new file mode 100644 index 0000000..3c38882 --- /dev/null +++ b/backend/handlers/payments/till_test.go @@ -0,0 +1,332 @@ +//go:build test && dev +// +build test,dev + +package payments + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "crussell/db" + "crussell/mw" + "crussell/testutils/fixtures" + "crussell/testutils/jwt" + + "github.com/go-chi/chi/v5" +) + +func TestCreateTillSale_OnTheHouse(t *testing.T) { + resetTestData(t) + + ctx := context.Background() + + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + + adminToken := jwt.GenerateTestToken(adminID, "admin") + + reqBody := TillSaleRequest{ + ItemType: "gift_card", + Action: "create", + Amount: 50.00, + PaymentMethod: "on_the_house", + } + + bodyBytes, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) + req.Header.Set("Authorization", "Bearer "+adminToken) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + + r := chi.NewRouter() + r.Use(mw.RequireAuth) + r.Post("/api/admin/till/sale", CreateTillSale) + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp TillSaleResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp.Status != "completed" { + t.Errorf("expected status 'completed', got '%s'", resp.Status) + } + if resp.PaymentMethod != "on_the_house" { + t.Errorf("expected payment method 'on_the_house', got '%s'", resp.PaymentMethod) + } + if resp.TotalAmount != 50.00 { + t.Errorf("expected total amount 50.00, got %.2f", resp.TotalAmount) + } + if resp.ItemType != "gift_card" { + t.Errorf("expected item type 'gift_card', got '%s'", resp.ItemType) + } + if resp.ItemID == nil || *resp.ItemID == "" { + t.Error("expected item_id to be set (gift card ID)") + } + + // Verify till_sale was created in DB + var saleCount int + err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales WHERE id = $1", resp.ID).Scan(&saleCount) + if err != nil { + t.Errorf("failed to query till_sales: %v", err) + } + if saleCount != 1 { + t.Errorf("expected 1 till_sale, got %d", saleCount) + } + + // Verify gift card was created + var gcCount int + err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE id = $1", *resp.ItemID).Scan(&gcCount) + if err != nil { + t.Errorf("failed to query gift_cards: %v", err) + } + if gcCount != 1 { + t.Errorf("expected 1 gift_card, got %d", gcCount) + } +} + +func TestCreateTillSale_Idempotency(t *testing.T) { + resetTestData(t) + + ctx := context.Background() + + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + + adminToken := jwt.GenerateTestToken(adminID, "admin") + + idempotencyKey := "test-idempotency-key-001" + + reqBody := TillSaleRequest{ + ItemType: "gift_card", + Action: "create", + Amount: 25.00, + PaymentMethod: "on_the_house", + IdempotencyKey: idempotencyKey, + } + + bodyBytes, _ := json.Marshal(reqBody) + + // First request + req1 := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) + req1.Header.Set("Authorization", "Bearer "+adminToken) + req1.Header.Set("Content-Type", "application/json") + + w1 := httptest.NewRecorder() + r := chi.NewRouter() + r.Use(mw.RequireAuth) + r.Post("/api/admin/till/sale", CreateTillSale) + r.ServeHTTP(w1, req1) + + if w1.Code != http.StatusCreated { + t.Errorf("first request: expected status 201, got %d. body: %s", w1.Code, w1.Body.String()) + } + + var resp1 TillSaleResponse + if err := json.NewDecoder(w1.Body).Decode(&resp1); err != nil { + t.Fatalf("failed to decode first response: %v", err) + } + + if resp1.ID == "" { + t.Fatal("expected first till_sale ID to be set") + } + + // Second request with same idempotency key + req2 := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) + req2.Header.Set("Authorization", "Bearer "+adminToken) + req2.Header.Set("Content-Type", "application/json") + + w2 := httptest.NewRecorder() + r2 := chi.NewRouter() + r2.Use(mw.RequireAuth) + r2.Post("/api/admin/till/sale", CreateTillSale) + r2.ServeHTTP(w2, req2) + + // Idempotent response returns 200 (the handler does not set WriteHeader in the idempotency path) + if w2.Code != http.StatusOK && w2.Code != http.StatusCreated { + t.Errorf("second request: expected status 200 or 201, got %d. body: %s", w2.Code, w2.Body.String()) + } + + var resp2 TillSaleResponse + if err := json.NewDecoder(w2.Body).Decode(&resp2); err != nil { + t.Fatalf("failed to decode second response: %v", err) + } + + if resp1.ID != resp2.ID { + t.Errorf("expected same till_sale ID for idempotent request, got %s and %s", resp1.ID, resp2.ID) + } + + // Verify only one till_sale exists + var saleCount int + err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales").Scan(&saleCount) + if err != nil { + t.Errorf("failed to query till_sales: %v", err) + } + if saleCount != 1 { + t.Errorf("expected 1 till_sale (idempotent), got %d", saleCount) + } +} + +func TestCreateTillSale_CreatesGiftCardTransaction(t *testing.T) { + resetTestData(t) + + ctx := context.Background() + + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + + adminToken := jwt.GenerateTestToken(adminID, "admin") + + reqBody := TillSaleRequest{ + ItemType: "gift_card", + Action: "create", + Amount: 50.00, + PaymentMethod: "on_the_house", + } + + bodyBytes, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) + req.Header.Set("Authorization", "Bearer "+adminToken) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + r := chi.NewRouter() + r.Use(mw.RequireAuth) + r.Post("/api/admin/till/sale", CreateTillSale) + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp TillSaleResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp.ItemID == nil || *resp.ItemID == "" { + t.Fatal("expected item_id to be set") + } + + // Verify gift_card_transactions was created + var txCount int + err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'purchase'", *resp.ItemID).Scan(&txCount) + if err != nil { + t.Errorf("failed to query gift_card_transactions: %v", err) + } + if txCount != 1 { + t.Errorf("expected 1 gift_card_transaction with type 'purchase', got %d", txCount) + } + + // Verify the transaction has reference_type = 'till_sale' and reference_id is set + var refCount int + err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id IS NOT NULL", *resp.ItemID).Scan(&refCount) + if err != nil { + t.Errorf("failed to query gift_card_transactions with reference: %v", err) + } + if refCount != 1 { + t.Errorf("expected 1 gift_card_transaction with reference_type 'till_sale' and reference_id set, got %d", refCount) + } +} + +func TestCreateTillSale_InvalidPaymentMethod(t *testing.T) { + resetTestData(t) + + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + + adminToken := jwt.GenerateTestToken(adminID, "admin") + + reqBody := TillSaleRequest{ + ItemType: "gift_card", + Action: "create", + Amount: 50.00, + PaymentMethod: "invalid_method", + } + + bodyBytes, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) + req.Header.Set("Authorization", "Bearer "+adminToken) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + r := chi.NewRouter() + r.Use(mw.RequireAuth) + r.Post("/api/admin/till/sale", CreateTillSale) + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestCreateTillSale_TopupOnRedeemedCard(t *testing.T) { + resetTestData(t) + + ctx := context.Background() + + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + + adminToken := jwt.GenerateTestToken(adminID, "admin") + + // Create a test user to act as the redeemer + redeemerID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create redeemer user: %v", err) + } + + // Insert a gift card that is already redeemed + var cardID string + err = db.DB.QueryRow(ctx, ` + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_by, redeemed_at) + VALUES (50.00, 0.00, $1, $2, NOW()) + RETURNING id + `, adminID, redeemerID).Scan(&cardID) + if err != nil { + t.Fatalf("failed to insert redeemed gift card: %v", err) + } + + // Try to topup the redeemed card + reqBody := TillSaleRequest{ + ItemType: "gift_card", + Action: "topup", + Amount: 25.00, + PaymentMethod: "on_the_house", + GiftCardID: &cardID, + } + + bodyBytes, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) + req.Header.Set("Authorization", "Bearer "+adminToken) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + r := chi.NewRouter() + r.Use(mw.RequireAuth) + r.Post("/api/admin/till/sale", CreateTillSale) + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) + } +}