package payments import ( "database/sql" "encoding/json" "errors" "fmt" "log" "net/http" "strconv" "strings" "time" "crussell/db" "crussell/internal/square" "crussell/internal/validators" "crussell/mw" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" ) // --- Types --- 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 string `json:"idempotency_key"` } 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 []interface{} gcListQuery := fmt.Sprintf(` SELECT id, total_funds_added, amount_remaining, created_at, is_inventory FROM gift_cards %s `, whereSQL) if searchTerm != "" { searchPattern := "%" + searchTerm + "%" gcListArgs = []interface{}{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 := []interface{}{} if searchTerm != "" { countArgs = append(countArgs, "%"+searchTerm+"%") } db.Conn.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards "+whereSQL, countArgs...).Scan(&gcTotal) } else { db.Conn.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards").Scan(&gcTotal) } 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 []interface{} 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 = []interface{}{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 = []interface{}{} } 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 != "" { 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) } else { db.Conn.QueryRow(ctx, "SELECT COUNT(*) FROM user_giftcard_balances").Scan(&ubTotal) } 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 w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } 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 tx.Rollback(ctx) var gc GiftCard var lastUsedAt sql.NullTime var purchaseVoucherType string _ = tx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType) if purchaseVoucherType == "" { purchaseVoucherType = "SPV" } err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, voucher_type_at_purchase) VALUES ($1, $1, $2, $3, NOW(), $4) RETURNING id, total_funds_added, amount_remaining, created_by, created_at, is_inventory, last_used_at `, req.Amount, adminID, req.IsInventory, purchaseVoucherType).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.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(gc) } 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 tx.Rollback(ctx) 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", 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 } 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() WHERE id = $2 RETURNING id, total_funds_added, amount_remaining, created_by, created_at `, req.Amount, cardID).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 } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(gc) } 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 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 tx.Rollback(ctx) var fromRedeemedBy, toRedeemedBy sql.NullString var fromRemaining, toRemaining float64 err = tx.QueryRow(ctx, "SELECT redeemed_by, amount_remaining FROM gift_cards WHERE id = $1", fromCardID).Scan(&fromRedeemedBy, &fromRemaining) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Source gift card not found", http.StatusNotFound) return } log.Printf("Failed to check source gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } err = tx.QueryRow(ctx, "SELECT redeemed_by, amount_remaining FROM gift_cards WHERE id = $1", req.ToCardID).Scan(&toRedeemedBy, &toRemaining) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Destination gift card not found", http.StatusNotFound) return } log.Printf("Failed to check destination gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } 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 } _, err = tx.Exec(ctx, ` UPDATE gift_cards SET amount_remaining = amount_remaining - $1, last_used_at = NOW() WHERE id = $2 `, req.Amount, fromCardID) 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() WHERE id = $2 `, req.Amount, req.ToCardID) 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) json.NewEncoder(w).Encode(map[string]string{"status": "success"}) } // --- 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 tx.Rollback(ctx) 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 } _, err = tx.Exec(ctx, ` UPDATE gift_cards SET amount_remaining = 0, redeemed_at = NOW(), redeemed_by = $1 WHERE id = $2 `, userID, code) 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) json.NewEncoder(w).Encode(map[string]interface{}{ "status": "success", "amount_redeemed": amountRemaining, }) } 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) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]float64{"balance": 0.00}) return } log.Printf("Failed to query user balance: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]float64{"balance": balance}) } // GetUserGiftCardBalanceAdmin Handler returns any user's balance for the admin. func GetUserGiftCardBalanceAdmin(w http.ResponseWriter, r *http.Request) { 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) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]float64{"balance": 0.00}) 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) if _, err := db.Conn.Exec(ctx, ` INSERT INTO admin_audit_log (admin_id, action_type, target_user_id, details) VALUES ($1, 'balance_check', $2, $3::jsonb) `, adminID, userID, detailsJSON); err != nil { log.Printf("Failed to record admin_audit_log (non-critical): %v", err) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]float64{"balance": balance}) } 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 } 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 { http.Error(w, err.Error(), http.StatusBadRequest) return } 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 if req.NewCardToken != nil && *req.NewCardToken != "" { cardOnFile, err := SquareClient.CreateCardOnFile(ctx, userID, *req.NewCardToken) if err != nil { log.Printf("Failed to create card on file: %v", err) http.Error(w, "Failed to process card", http.StatusInternalServerError) return } sourceID = cardOnFile.CardID if req.SaveCard { cardID, err := paymentService.SaveCardForUser(ctx, userID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint) if err != nil { log.Printf("Failed to save card: %v", err) } else { savedCardID = &cardID } } } else if req.CardID != nil { card, err := paymentService.GetCardByID(ctx, *req.CardID, userID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Card not found", http.StatusNotFound) return } log.Printf("Failed to get card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } sourceID = card.SquareCardID savedCardID = req.CardID } paymentReq := square.CreatePaymentReq{ Amount: req.Amount, Currency: "GBP", SourceID: sourceID, IdempotencyKey: req.IdempotencyKey, Note: "Gift Card Purchase", } paymentResult, err := SquareClient.CreatePayment(ctx, paymentReq) if err != nil { log.Printf("Failed to process gift card purchase payment: %v", err) http.Error(w, "Payment failed", http.StatusPaymentRequired) 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 tx.Rollback(ctx) amountPounds := float64(req.Amount) / 100.0 var cardID string if req.RecipientType == "self" { var purchaseVoucherType string _ = tx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType) if purchaseVoucherType == "" { purchaseVoucherType = "SPV" } err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by, is_inventory, voucher_type_at_purchase) VALUES ($1, 0, $2, NOW(), $2, FALSE, $3) RETURNING id `, amountPounds, userID, purchaseVoucherType).Scan(&cardID) if err != nil { log.Printf("Failed to insert 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, amountPounds) if err != nil { log.Printf("Failed to update 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, '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 { var purchaseVoucherType string _ = tx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType) if purchaseVoucherType == "" { purchaseVoucherType = "SPV" } err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase) VALUES ($1, $1, $2, FALSE, $3) RETURNING id `, amountPounds, userID, purchaseVoucherType).Scan(&cardID) if err != nil { log.Printf("Failed to insert gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } recipient := req.RecipientEmail if recipient == "" { var userEmail string _ = tx.QueryRow(ctx, "SELECT email FROM users WHERE id = $1", userID).Scan(&userEmail) 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") record := PaymentRecord{ PaymentType: "full", PaymentMethod: "online_square", Status: "completed", Amount: amountPounds, SquarePaymentID: &paymentResult.SquarePayID, IdempotencyKey: &req.IdempotencyKey, Fees: float64(fees) / 100.0, UserSavedCardID: savedCardID, CreatedAt: time.Now(), UpdatedAt: time.Now(), CreatedBy: &userID, } var buyPaymentID string 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 } 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 } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]interface{}{ "status": "success", "code": cardID, "amount": amountPounds, }) } // --- 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 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.Conn.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.Conn.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"}) }