Files
Crussell/backend/handlers/payments/giftcards.go
T
popertotsandSisyphus 7b24f8e484 refactor(payments): integrate VAT into gift card buy flow and wrap in transactions
Refactor BuyGiftCard to insert pending payment before Square call with VAT applied. Add transaction wrapping to gift card handlers. Remove redundant Content-Type header sets. Migrate all time.Now() to clock.Now().

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:40 +01:00

1217 lines
36 KiB
Go

package payments
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"crussell/db"
"crussell/clock"
"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
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
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"
}
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.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, &currentTotalFunds)
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
}
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) {
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
}
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) {
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)
tx, err := db.Conn.Begin(ctx)
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
} else {
defer tx.Rollback(ctx)
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)
}
}
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 {
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
}
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 tx.Rollback(ctx)
var buyPaymentID string
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
}
// Step 2: DB transaction committed — safe to call Square now.
// If Square fails, the payment record stays 'pending' for manual retry.
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)
// Payment record intentionally left as 'pending' for manual retry.
http.Error(w, "Payment failed", http.StatusPaymentRequired)
return
}
// Step 3: Square succeeded — update payment, create gift card.
_, upErr := db.Conn.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
if req.RecipientType == "self" {
var purchaseVoucherType string
err = db.Conn.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 = db.Conn.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("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 = db.Conn.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 = db.Conn.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 = db.Conn.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 = db.Conn.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("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 = db.Conn.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
}
log.Printf("[TODO EMAIL] Send gift card code %s (Value: £%.2f) to %s", cardID, amountPounds, recipient)
_, err = db.Conn.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
}
}
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, &notes)
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 = &notes.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{}
}
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
}
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 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)
json.NewEncoder(w).Encode(map[string]string{"status": "claimed"})
}