Files
Crussell/backend/handlers/payments/giftcards.go
T
popertots 197d4c4b9b Gift-card rolling expiry, SvelteDate→Date purge, strict DST tests, UTC scan-location + settings legal floor
Gift-card rolling expiry (setting-driven, was dead config):
- GetGiftCardExpiryMonths(): single source of truth (business_settings
  gift_card_expiry_months, fallback 24) shared by payment handlers and the
  CleanupExpiredGiftCards job (was hardcoded 24).
- expiry_date now maintained on ALL 9 gift-card write sites (buy, topup,
  transfer, redeem, terminal payment, refund credit, till) so the refund-time
  guard at refunds.go actually fires. Schema default 12->24 + migration note;
  test-DB seed aligned. Stale "expiry_date IS NULL" test rewritten; new
  expired-card-rejected regression test.

Frontend SvelteDate purge (docs' stated convention, wide):
- All 180+ raw `new SvelteDate(...)` uses across routes/components replaced
  with parseWallClockDate (backend UTC ISO) or new Date (wall-clock
  constructors). SvelteDate imports removed. timeSlots.ts getDayWithOrdinal
  fixed. Zero SvelteDate references remain; svelte-check clean.

Strict timezone/DST testing + QA fixes:
- 8 new hermetic boundary tests: clock.DST transitions (both 2026 folds),
  closing-hours GMT vs BST, booking date-window midnight, refund-tier
  elapsed-time independence, deposit-window UTC-instant, scheduling
  LondonDateString midnight, today AT TIME ZONE window + UTC round-trip.
- today.go summary date labels fixed to London wall-clock (were showing the
  previous UTC day during BST) + regression test.
- pgx ScanLocation fixed to UTC via AfterConnect (was host-local -> JSON
  offsets depended on deployment TZ, contradicting the documented UTC
  invariant) + regression test. Registered as a new *Type to avoid a data
  race on the shared type map (caught by -race).

Admin Business Settings (setting now functional => legal floor):
- gift_card_expiry_months validation floor raised 1 -> 12 months (CMA/
  Consumer Rights Act 2015 unfair-contract-term guidance) in endpoint + UI,
  with rolling-expiry semantics shown in both display and edit form.
- 3 new expiry validation tests; 2 pre-existing message assertions updated.

Full suite 25/25 + race clean via run-tests.sh lockfile; svelte-check 0
errors/warnings; production build succeeds.
2026-08-22 00:34:49 +01:00

1489 lines
49 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, &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
}
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, &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{}
}
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)
}
}