package payments import ( "context" "database/sql" "encoding/json" "errors" "fmt" "log" "log/slog" "math" "net/http" "strconv" "strings" "time" "crussell/clock" "crussell/db" "crussell/internal/square" "crussell/internal/validators" "crussell/mw" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" ) // --- Types --- // defaultGiftCardExpiryMonths is the CMA-recommended rolling expiry window used // whenever business_settings.gift_card_expiry_months is unset or invalid (< 1). const defaultGiftCardExpiryMonths = 24 // GetGiftCardExpiryMonths returns the configured gift-card expiry window in // months — the SINGLE source of truth for rolling expiry. It reads // business_settings.gift_card_expiry_months and falls back to // defaultGiftCardExpiryMonths when the settings row is missing (pgx.ErrNoRows) // or the stored value is < 1 (a sub-1-month window would make every card // effectively expired on creation). Exported so the scheduling package's // CleanupExpiredGiftCards job and the payment handlers share one implementation. func GetGiftCardExpiryMonths(ctx context.Context, q db.Querier) (int, error) { var months int if err := q.QueryRow(ctx, `SELECT gift_card_expiry_months FROM business_settings LIMIT 1`).Scan(&months); err != nil { if errors.Is(err, pgx.ErrNoRows) { return defaultGiftCardExpiryMonths, nil } return 0, err } if months < 1 { return defaultGiftCardExpiryMonths, nil } return months, nil } type GiftCard struct { ID string `json:"id"` TotalFundsAdded float64 `json:"total_funds_added"` AmountRemaining float64 `json:"amount_remaining"` CreatedBy *string `json:"created_by,omitempty"` 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 { UserID string `json:"user_id"` Name string `json:"name"` Email string `json:"email"` Balance float64 `json:"balance"` UpdatedAt time.Time `json:"updated_at"` } 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"` NextCursor *string `json:"next_cursor,omitempty"` } type CreateGiftCardRequest struct { Amount float64 `json:"amount"` IsInventory bool `json:"is_inventory,omitempty"` } type TopUpGiftCardRequest struct { Amount float64 `json:"amount"` PaymentMethod string `json:"payment_method"` } type TransferGiftCardRequest struct { ToCardID string `json:"to_card_id"` Amount float64 `json:"amount"` } type BuyGiftCardRequest struct { Amount int64 `json:"amount"` RecipientType string `json:"recipient_type"` RecipientEmail string `json:"recipient_email,omitempty"` CardID *string `json:"card_id,omitempty"` NewCardToken *string `json:"new_card_token,omitempty"` SaveCard bool `json:"save_card"` // IdempotencyKey is required (R2): an empty key would be stored as '' on // the pending payments row, and a second empty-key purchase would 500 on // the UNIQUE(payments.idempotency_key) constraint. The frontend always // sends a per-purchase UUID; max=45 matches Square's /v2/payments limit. IdempotencyKey string `json:"idempotency_key" validate:"required,max=45"` VerificationToken *string `json:"verification_token,omitempty"` } type RedeemGiftCardRequest struct { Code string `json:"code"` } // --- Admin Handlers --- func GetGiftCards(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Parse query parameters query := r.URL.Query() searchTerm := query.Get("q") // Pagination parameters perPage := 10 cursorStr := query.Get("cursor") if perPageStr := query.Get("per_page"); perPageStr != "" { if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 { perPage = pp } } // Accept page param for backward compat (deprecated) page := 1 if pageStr := query.Get("page"); pageStr != "" { if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { page = p } } var resp GiftCardListResponse resp.GiftCards = []GiftCard{} resp.UserBalances = []UserBalance{} resp.Page = page resp.PerPage = perPage // --- Aggregate totals (unfiltered, unpaginated) --- err := db.Conn.QueryRow(ctx, ` SELECT COALESCE(SUM(amount_remaining), 0) FROM gift_cards WHERE redeemed_by IS NULL `).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) return } err = db.Conn.QueryRow(ctx, ` SELECT COALESCE(SUM(balance), 0) FROM user_giftcard_balances `).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 } // --- 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 gcListArgs []any gcListQuery := fmt.Sprintf(` SELECT id, total_funds_added, amount_remaining, created_at, is_inventory FROM gift_cards %s `, whereSQL) if searchTerm != "" { searchPattern := "%" + searchTerm + "%" gcListArgs = []any{searchPattern} if cursorStr != "" { cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr) if err != nil { http.Error(w, "invalid cursor: "+err.Error(), http.StatusBadRequest) return } gcListQuery += " AND (created_at, id) < ($2, $3)" gcListArgs = append(gcListArgs, cursorCreatedAt, cursorID) } gcListQuery += " ORDER BY created_at DESC, id DESC LIMIT $" + strconv.Itoa(len(gcListArgs)+1) gcListArgs = append(gcListArgs, perPage+1) } else { if cursorStr != "" { cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr) if err != nil { http.Error(w, "invalid cursor: "+err.Error(), http.StatusBadRequest) return } gcListQuery += " WHERE (created_at, id) < ($1, $2)" gcListArgs = append(gcListArgs, cursorCreatedAt, cursorID) } gcListQuery += " ORDER BY created_at DESC, id DESC LIMIT $" + strconv.Itoa(len(gcListArgs)+1) gcListArgs = append(gcListArgs, perPage+1) } // Count query: total matching gift cards (same WHERE, without cursor/ORDER BY/LIMIT). // Run BEFORE the data query to avoid "conn busy" when using a per-test // transaction (single connection). gcTotal = 0 if whereSQL != "" { countArgs := []any{} if searchTerm != "" { countArgs = append(countArgs, "%"+searchTerm+"%") } if err := db.Conn.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards "+whereSQL, countArgs...).Scan(&gcTotal); err != nil { log.Printf("Failed to scan filtered gift card count: %v", err) } } else { if err := db.Conn.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards").Scan(&gcTotal); err != nil { log.Printf("Failed to scan gift card count: %v", err) } } gcRows, err := db.Conn.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 gcRows.Close() for gcRows.Next() { var gc GiftCard err = gcRows.Scan( &gc.ID, &gc.TotalFundsAdded, &gc.AmountRemaining, &gc.CreatedAt, &gc.IsInventory, ) if err != nil { log.Printf("Failed to scan gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } resp.GiftCards = append(resp.GiftCards, gc) } // --- User balances (all, optionally filtered) --- var ubTotal int var ubListQuery string var ubListArgs []any if searchTerm != "" { searchPattern := "%" + searchTerm + "%" ubListQuery = ` 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 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 = []any{searchPattern} } else { ubListQuery = ` 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 ` ubListArgs = []any{} } ubRows, err := db.Conn.Query(ctx, ubListQuery, ubListArgs...) if err != nil { log.Printf("Failed to query user balances: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer ubRows.Close() // Count query for user balances. if searchTerm != "" { if err := db.Conn.QueryRow(ctx, `SELECT COUNT(*) 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`, "%"+searchTerm+"%").Scan(&ubTotal); err != nil { log.Printf("Failed to scan filtered user balance count: %v", err) } } else { if err := db.Conn.QueryRow(ctx, "SELECT COUNT(*) FROM user_giftcard_balances").Scan(&ubTotal); err != nil { log.Printf("Failed to scan user balance count: %v", err) } } for ubRows.Next() { var ub UserBalance err = ubRows.Scan( &ub.UserID, &ub.Name, &ub.Email, &ub.Balance, &ub.UpdatedAt, ) if err != nil { log.Printf("Failed to scan user balance: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } resp.UserBalances = append(resp.UserBalances, ub) } resp.UBTotal = ubTotal resp.Total = gcTotal var nextCursor *string if len(resp.GiftCards) > perPage { resp.GiftCards = resp.GiftCards[:perPage] last := resp.GiftCards[len(resp.GiftCards)-1] cursor := last.CreatedAt.Format(time.RFC3339) + "|" + last.ID nextCursor = &cursor } resp.NextCursor = nextCursor totalPages := (gcTotal + perPage - 1) / perPage if totalPages == 0 { totalPages = 1 } resp.TotalPages = totalPages if err := json.NewEncoder(w).Encode(resp); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } func CreateGiftCard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() adminID, _ := ctx.Value(mw.UserIDKey).(string) var req CreateGiftCardRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } 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 } tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() var gc GiftCard var lastUsedAt sql.NullTime var purchaseVoucherType string err = tx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType) if err != nil { log.Printf("Failed to query voucher type: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if purchaseVoucherType == "" { purchaseVoucherType = "SPV" } expiryMonths, err := GetGiftCardExpiryMonths(ctx, tx) if err != nil { log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err) expiryMonths = defaultGiftCardExpiryMonths } err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase) VALUES ($1, $1, $2, $3, NOW(), NOW() + ($5 * INTERVAL '1 month'), $4) RETURNING id, total_funds_added, amount_remaining, created_by, created_at, is_inventory, last_used_at `, req.Amount, adminID, req.IsInventory, purchaseVoucherType, expiryMonths).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) http.Error(w, "internal server error", http.StatusInternalServerError) return } 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 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 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) return } w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(gc); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } 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) return } var req TopUpGiftCardRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } if req.Amount <= 0 { http.Error(w, "Amount must be greater than zero", http.StatusBadRequest) 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.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() var redeemedBy sql.NullString var isInventory bool var currentTotalFunds float64 err = tx.QueryRow(ctx, "SELECT redeemed_by, is_inventory, total_funds_added FROM gift_cards WHERE id = $1 FOR UPDATE", cardID).Scan(&redeemedBy, &isInventory, ¤tTotalFunds) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Gift card not found", http.StatusNotFound) return } log.Printf("Failed to check gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if redeemedBy.Valid { http.Error(w, "Cannot top up a card that has already been redeemed to an account", http.StatusBadRequest) return } expiryMonths, err := GetGiftCardExpiryMonths(ctx, tx) if err != nil { log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err) expiryMonths = defaultGiftCardExpiryMonths } 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, last_used_at = NOW(), expiry_date = NOW() + ($3 * INTERVAL '1 month') WHERE id = $2 RETURNING id, total_funds_added, amount_remaining, created_by, created_at `, req.Amount, cardID, expiryMonths).Scan( &gc.ID, &gc.TotalFundsAdded, &gc.AmountRemaining, &createdBy, &gc.CreatedAt, ) if err != nil { log.Printf("Failed to top up gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if createdBy.Valid { 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) return } if err := json.NewEncoder(w).Encode(gc); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } func TransferGiftCard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() fromCardID := chi.URLParam(r, "from") if fromCardID == "" || !validators.IsValidID(fromCardID) { http.Error(w, "Invalid source gift card ID", http.StatusBadRequest) return } var req TransferGiftCardRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } req.ToCardID = validators.NormalizeGiftCardCode(req.ToCardID) if !validators.IsValidID(req.ToCardID) { http.Error(w, "Invalid destination gift card ID", http.StatusBadRequest) return } if strings.EqualFold(fromCardID, req.ToCardID) { http.Error(w, "Source and destination cards must be different", http.StatusBadRequest) return } if req.Amount <= 0 { http.Error(w, "Amount must be greater than zero", http.StatusBadRequest) return } tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() var fromRedeemedBy, toRedeemedBy sql.NullString var fromRemaining, toRemaining float64 // Lock both rows FOR UPDATE in a globally deterministic order (lesser ID // first, then greater) so two concurrent cross-transfers (A→B and B→A) // acquire the locks in the same order and can never deadlock. Locking // "source first" is only deterministic per-request — the caller picks the // source, so opposite transfers would deadlock (N-5). IDs are compared // case-insensitively: gift card codes are 12-hex that may arrive mixed-case // from the URL param vs the body, and the same two cards must always sort // the same way for every concurrent transaction. lockFirstID, lockSecondID := fromCardID, req.ToCardID if strings.ToLower(lockFirstID) > strings.ToLower(lockSecondID) { lockFirstID, lockSecondID = lockSecondID, lockFirstID } lockFirstIsSource := strings.EqualFold(lockFirstID, fromCardID) err = tx.QueryRow(ctx, "SELECT redeemed_by, amount_remaining FROM gift_cards WHERE id = $1 FOR UPDATE", lockFirstID).Scan(&fromRedeemedBy, &fromRemaining) if err != nil { if errors.Is(err, pgx.ErrNoRows) { if lockFirstIsSource { http.Error(w, "Source gift card not found", http.StatusNotFound) } else { http.Error(w, "Destination gift card not found", http.StatusNotFound) } return } log.Printf("Failed to check gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // Lock the second row in the same order so both concurrent transactions // hold the same lock sequence and can never deadlock. err = tx.QueryRow(ctx, "SELECT redeemed_by, amount_remaining FROM gift_cards WHERE id = $1 FOR UPDATE", lockSecondID).Scan(&toRedeemedBy, &toRemaining) if err != nil { if errors.Is(err, pgx.ErrNoRows) { if lockFirstIsSource { http.Error(w, "Destination gift card not found", http.StatusNotFound) } else { http.Error(w, "Source gift card not found", http.StatusNotFound) } return } log.Printf("Failed to check gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // The lock order may differ from the source/destination roles when the // destination ID sorts before the source ID; swap the scanned values back // so the business logic below always treats fromCardID as the source. if !lockFirstIsSource { fromRedeemedBy, toRedeemedBy = toRedeemedBy, fromRedeemedBy fromRemaining, toRemaining = toRemaining, fromRemaining } if fromRedeemedBy.Valid || toRedeemedBy.Valid { http.Error(w, "Cannot transfer balance to/from cards redeemed to accounts", http.StatusBadRequest) return } if fromRemaining < req.Amount { http.Error(w, "Insufficient balance on source gift card", http.StatusBadRequest) return } // A transfer is a "use" of BOTH cards per the rolling-expiry terms — each // card's timer resets at the same moment its balance moves. expiryMonths, err := GetGiftCardExpiryMonths(ctx, tx) if err != nil { log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err) expiryMonths = defaultGiftCardExpiryMonths } _, err = tx.Exec(ctx, ` UPDATE gift_cards SET amount_remaining = amount_remaining - $1, last_used_at = NOW(), expiry_date = NOW() + ($3 * INTERVAL '1 month') WHERE id = $2 `, req.Amount, fromCardID, expiryMonths) if err != nil { log.Printf("Failed to deduct from source: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } _, err = tx.Exec(ctx, ` UPDATE gift_cards SET amount_remaining = amount_remaining + $1, total_funds_added = total_funds_added + $1, last_used_at = NOW(), expiry_date = NOW() + ($3 * INTERVAL '1 month') WHERE id = $2 `, req.Amount, req.ToCardID, expiryMonths) if err != nil { log.Printf("Failed to add to destination: %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) return } w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(map[string]string{"status": "success"}); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // --- User Handlers --- func RedeemGiftCard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() userID, ok := ctx.Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } var req RedeemGiftCardRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } code := validators.NormalizeGiftCardCode(req.Code) if !validators.IsValidID(code) { http.Error(w, "Invalid gift card code format", http.StatusBadRequest) return } tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() var amountRemaining float64 var redeemedBy sql.NullString err = tx.QueryRow(ctx, ` SELECT amount_remaining, redeemed_by FROM gift_cards WHERE id = $1 FOR UPDATE `, code).Scan(&amountRemaining, &redeemedBy) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Invalid or expired gift card code", http.StatusNotFound) return } log.Printf("Failed to query gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if redeemedBy.Valid { http.Error(w, "This gift card has already been redeemed", http.StatusBadRequest) return } if amountRemaining <= 0 { http.Error(w, "This gift card has no remaining balance", http.StatusBadRequest) return } expiryMonths, err := GetGiftCardExpiryMonths(ctx, tx) if err != nil { log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err) expiryMonths = defaultGiftCardExpiryMonths } _, err = tx.Exec(ctx, ` UPDATE gift_cards SET amount_remaining = 0, redeemed_at = NOW(), redeemed_by = $1, last_used_at = NOW(), expiry_date = NOW() + ($3 * INTERVAL '1 month') WHERE id = $2 `, userID, code, expiryMonths) if err != nil { log.Printf("Failed to update gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } _, err = tx.Exec(ctx, ` INSERT INTO user_giftcard_balances (user_id, balance, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (user_id) DO UPDATE SET balance = user_giftcard_balances.balance + EXCLUDED.balance, updated_at = NOW() `, userID, amountRemaining) if err != nil { log.Printf("Failed to update user gift card balance: %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, '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) return } w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(map[string]any{ "status": "success", "amount_redeemed": amountRemaining, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } func GetGiftCardBalance(w http.ResponseWriter, r *http.Request) { ctx := r.Context() userID, ok := ctx.Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } var balance float64 err := db.Conn.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance) if err != nil { if errors.Is(err, pgx.ErrNoRows) { if err := json.NewEncoder(w).Encode(map[string]float64{"balance": 0.00}); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return } log.Printf("Failed to query user balance: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if err := json.NewEncoder(w).Encode(map[string]float64{"balance": balance}); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // GetUserGiftCardBalanceAdmin Handler returns any user's balance for the admin. func GetUserGiftCardBalanceAdmin(w http.ResponseWriter, r *http.Request) { if !isAdminRequest(r) { http.Error(w, "Unauthorized", http.StatusForbidden) return } ctx := r.Context() userID := chi.URLParam(r, "id") if userID == "" || !validators.IsValidID(userID) { http.Error(w, "Invalid user ID", http.StatusBadRequest) return } adminID, _ := ctx.Value(mw.UserIDKey).(string) var balance float64 err := db.Conn.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance) if err != nil { if errors.Is(err, pgx.ErrNoRows) { if err := json.NewEncoder(w).Encode(map[string]float64{"balance": 0.00}); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return } log.Printf("Failed to query user balance: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } detailsJSON := fmt.Sprintf(`{"balance": %.2f}`, balance) tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction: %v", err) } else { defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() if _, err := tx.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) } if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit transaction: %v", err) } } if err := json.NewEncoder(w).Encode(map[string]float64{"balance": balance}); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } func BuyGiftCard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() userID, ok := ctx.Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } var req BuyGiftCardRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } // R2: idempotency_key is required (validate:"required,max=45"). An empty // key would be stored as '' on the pending payments row and a second // empty-key purchase would 500 on the UNIQUE(payments.idempotency_key) // constraint. The frontend always sends a per-purchase UUID. The fallback // below is defense-in-depth only — the validator rejects the empty key // first, but if it is ever relaxed the fallback keeps the UNIQUE // constraint from firing. if err := validators.Validate.Struct(&req); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } if req.IdempotencyKey == "" { req.IdempotencyKey = uniqueChargeKey("gc-") } allowedAmounts := map[int64]bool{1000: true, 2000: true, 5000: true} if !allowedAmounts[req.Amount] { http.Error(w, "Invalid amount. Must be £10, £20, or £50.", http.StatusBadRequest) return } if req.RecipientType != "self" && req.RecipientType != "friend" { http.Error(w, "Invalid recipient type", http.StatusBadRequest) return } if err := ValidateCardInfo(req.CardID, req.NewCardToken); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } if err := ValidateVerificationToken(req.VerificationToken); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } paymentService := NewPaymentService() // Serialize gift-card purchase attempts on the idempotency key to prevent // concurrent same-key retries from both reusing a pending record and both // executing the gift-card creation (2× value for 1 charge). Mirrors the tip // advisory-lock pattern (handlers.go). Lock is keyed on the idempotency key // so distinct purchases are unaffected; falls back to userID when absent. // Bounded try-lock (R6) so a contended lock never blocks the pool across // the Square round-trip. lockKey := req.IdempotencyKey if lockKey == "" { lockKey = userID } pinConn, lockOK := acquireBookingPaymentLock(ctx, w, "crussell:giftcard:"+lockKey, "Purchase in progress, try again") if !lockOK { return } defer releaseBookingPaymentLock(pinConn, "crussell:giftcard:"+lockKey) // Idempotency: only short-circuit when the existing record is 'completed'. // A 'pending' record means the previous Square call failed — returning it // as 200 would show a success without ever charging. Re-attempt below with // the same key (Square dedups safely) and reuse the pending record. reusePendingID := "" if req.IdempotencyKey != "" { existing, err := paymentService.CheckIdempotencyByKey(ctx, req.IdempotencyKey) if err != nil { log.Printf("Failed to check idempotency: %v", err) } if existing != nil { if existing.Status == "completed" { if err := json.NewEncoder(w).Encode(existing); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return } if existing.Status == "pending" { // Guard the amount: a retry with a different amount must not // reuse the pending record (gift card would be issued at the // new amount against the old charge record). if int64(math.Round(existing.Amount*100)) != req.Amount { log.Printf("Gift card retry amount mismatch: pending record %s has %.2f, request has %d pence", existing.ID, existing.Amount, req.Amount) http.Error(w, "Amount does not match the pending gift card payment", http.StatusBadRequest) return } reusePendingID = existing.ID log.Printf("[PAYMENTS] Reusing pending payment %s for idempotent gift-card retry (key %s)", existing.ID, req.IdempotencyKey) } if existing.Status == "failed" { // Swept as stale (>24h) or definitively rejected — a retry would // risk a second Square charge. Reject cleanly (R2). log.Printf("Gift card retry rejected: pending record %s was marked failed", existing.ID) http.Error(w, "This gift card purchase previously failed and can no longer be retried", http.StatusConflict) return } } } var sourceID string var savedCardID *string var savedCardCustomerID string // Resolve the new-card-vs-saved-card Square source — shared with // CreateBookingPayment/CreateTipPayment (see resolveChargeSource for the // R6 rationale). sourceID, savedCardID, savedCardCustomerID, sourceOK := resolveChargeSource(ctx, w, paymentService, userID, req.NewCardToken, req.CardID, req.SaveCard, "Card not found") if !sourceOK { return } amountPounds := float64(req.Amount) / 100.0 // Step 1: Insert payment with status='pending' inside a DB transaction. // Square is NOT called yet — if the tx fails, no harm done. // Gift card and balance are created AFTER payment succeeds (below). tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() var buyPaymentID string if reusePendingID != "" { // Reusing the pending record from a failed prior attempt — do not // insert a duplicate (idempotency_key is UNIQUE). Proceed straight to // the Square call, which dedups on the same key. buyPaymentID = reusePendingID } else { fees := paymentService.CalculateFees(req.Amount, "online") record := PaymentRecord{ PaymentType: "full", PaymentMethod: "online_square", Status: "pending", Amount: amountPounds, SquarePaymentID: nil, IdempotencyKey: &req.IdempotencyKey, Fees: float64(fees) / 100.0, UserSavedCardID: savedCardID, CreatedAt: clock.Now(), UpdatedAt: clock.Now(), CreatedBy: &userID, } err = tx.QueryRow(ctx, ` INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, idempotency_key, fees, user_saved_card_id, created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id `, record.PaymentType, record.PaymentMethod, record.Status, record.Amount, record.SquarePaymentID, record.IdempotencyKey, record.Fees, record.UserSavedCardID, record.CreatedBy, record.CreatedAt, record.UpdatedAt).Scan(&buyPaymentID) if err != nil { log.Printf("Failed to insert payment record: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // Apply VAT to the pending payment vatCfg, vatErr := GetVATConfig(ctx, tx) if vatErr == nil && vatCfg.IsVATRegistered && vatCfg.VoucherType == "SPV" { if _, vatExecErr := tx.Exec(ctx, "SELECT apply_vat_to_payment($1, $2)", buyPaymentID, vatCfg.DefaultVATRate); vatExecErr != nil { log.Printf("Failed to apply VAT to buy gift card payment %s: %v", buyPaymentID, vatExecErr) } } if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit buy transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } } // Commit in the reuse path too. No rows were written, but the commit is // required in the test harness: there the context carries an outer test tx, // so Begin creates a nested savepoint whose deferred rollback would // otherwise undo the status UPDATE executed later on the same connection. // In production Begin is a plain tx and this commit is a harmless no-op. if reusePendingID != "" { if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit buy transaction (reuse): %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } } // Step 2: DB transaction committed — safe to call Square now. // If Square fails, the payment record stays 'pending' for manual retry. var buyerEmail string if err := db.Conn.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&buyerEmail); err != nil { log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", userID, err) } var verificationToken string if req.VerificationToken != nil { verificationToken = *req.VerificationToken } paymentReq := square.CreatePaymentReq{ Amount: req.Amount, Currency: "GBP", SourceID: sourceID, // CustomerID carries the saved-card row's Square customer id on ccof: // charges (save-card path); a cnon: nonce charge (one-off) needs none // (R6). CustomerID: savedCardCustomerID, IdempotencyKey: req.IdempotencyKey, Note: "Gift Card Purchase", BuyerEmail: buyerEmail, VerificationToken: verificationToken, } paymentResult, err := SquareClient.CreatePayment(ctx, paymentReq) if err != nil { log.Printf("Failed to process gift card purchase payment: %v", err) // Payment record intentionally left as 'pending' for manual retry. http.Error(w, "Payment failed", chargeFailureStatus(err)) return } // Step 3: Square succeeded — atomically flip the payment to completed and // create the gift card + balance + transaction in ONE transaction. If any // step fails, the whole thing rolls back, the payment stays 'pending', and // a same-key retry re-attempts the Square charge (Square dedups) before // delivering the card. Previously these were separate non-transactional // writes: a failure after the payment-completed update left the customer // CHARGED but with no card, and the completed-dedup swallowed the retry. issueTx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin gift-card issue transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer func() { if err := issueTx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback gift-card issue transaction", "err", err) } }() _, upErr := issueTx.Exec(ctx, `UPDATE payments SET status = 'completed', square_payment_id = $1 WHERE id = $2`, paymentResult.SquarePayID, buyPaymentID, ) if upErr != nil { log.Printf("CRITICAL: Square payment succeeded (ID=%s) but payment %s update failed: %v — manual reconciliation required", paymentResult.SquarePayID, buyPaymentID, upErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } var cardID string expiryMonths, err := GetGiftCardExpiryMonths(ctx, issueTx) if err != nil { log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err) expiryMonths = defaultGiftCardExpiryMonths } if req.RecipientType == "self" { var purchaseVoucherType string err = issueTx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType) if err != nil { log.Printf("Failed to query voucher type: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if purchaseVoucherType == "" { purchaseVoucherType = "SPV" } err = issueTx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase) VALUES ($1, 0, $2, NOW(), $2, FALSE, NOW(), NOW() + ($4 * INTERVAL '1 month'), $3) RETURNING id `, amountPounds, userID, purchaseVoucherType, expiryMonths).Scan(&cardID) if err != nil { log.Printf("CRITICAL: Square payment succeeded (ID=%s) but gift card creation failed: %v — manual reconciliation required", paymentResult.SquarePayID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } _, err = issueTx.Exec(ctx, ` INSERT INTO user_giftcard_balances (user_id, balance, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (user_id) DO UPDATE SET balance = user_giftcard_balances.balance + EXCLUDED.balance, updated_at = NOW() `, userID, amountPounds) if err != nil { log.Printf("CRITICAL: Square payment succeeded (ID=%s) but balance update for user %s failed: %v — manual reconciliation required", paymentResult.SquarePayID, userID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } _, err = issueTx.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("CRITICAL: Square payment succeeded (ID=%s) but gift card transaction record failed: %v — manual reconciliation required", paymentResult.SquarePayID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } } else { var purchaseVoucherType string err = issueTx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType) if err != nil { log.Printf("Failed to query voucher type: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if purchaseVoucherType == "" { purchaseVoucherType = "SPV" } err = issueTx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase) VALUES ($1, $1, $2, FALSE, NOW(), NOW() + ($4 * INTERVAL '1 month'), $3) RETURNING id `, amountPounds, userID, purchaseVoucherType, expiryMonths).Scan(&cardID) if err != nil { log.Printf("CRITICAL: Square payment succeeded (ID=%s) but gift card creation failed: %v — manual reconciliation required", paymentResult.SquarePayID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } recipient := req.RecipientEmail if recipient == "" { var userEmail string err = issueTx.QueryRow(ctx, "SELECT email FROM users WHERE id = $1", userID).Scan(&userEmail) if err != nil { log.Printf("Failed to query user email: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } recipient = userEmail } // TODO: send gift card code via email to recipient once SMTP is wired. // Do NOT log the spendable gift-card code — it is a credential (12-digit // code anyone can redeem). Log only the value and recipient for audit. log.Printf("Gift card purchased for friend — value: £%.2f, intended for: %s (code stored in DB, not logged)", amountPounds, recipient) _, err = issueTx.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("CRITICAL: Square payment succeeded (ID=%s) but gift card transaction record failed: %v — manual reconciliation required", paymentResult.SquarePayID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } } if err := issueTx.Commit(ctx); err != nil { log.Printf("CRITICAL: Square payment succeeded (ID=%s) but gift-card issue transaction commit failed: %v — manual reconciliation required", paymentResult.SquarePayID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(map[string]any{ "status": "success", "code": cardID, "amount": amountPounds, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // --- Helpers --- 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.Conn.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 err := rows.Err(); err != nil { log.Printf("Row iteration error: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if balances == nil { balances = []ExpiredBalance{} } if err := json.NewEncoder(w).Encode(map[string]any{ "expired_balances": balances, "total": len(balances), }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } 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 } tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() var existingClaimedAt sql.NullTime err = tx.QueryRow(ctx, ` SELECT claimed_at FROM gift_card_expired_balances WHERE id = $1 FOR UPDATE `, 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 = tx.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 } if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(map[string]string{"status": "claimed"}); err != nil { log.Printf("Failed to encode JSON response: %v", err) } }