Loop B restart (money/security/dup-mod adversarial) fixes: - CRITICAL: CreateTerminalPayment rejects payment_type='tip' (mirrors CreateBookingPayment) — a tip-typed admin charge no longer records the FULL amount as a tip and double-collects (all is-paid computations exclude tip rows) - HIGH: tip refunds can no longer re-open booking capacity — refunded_total subqueries filter payment_type <> 'tip' (service.go) and RefundPayment rejects tip rows - MEDIUM: loyalty-stamp farming closed — stamp award once-per-booking via loyalty_stamp_awarded_at column (init-script.sql) + existing same-day guard - MEDIUM: CreateTipPayment/CreateBookingPayment 2FA gates moved AFTER the idempotency completed-dedup (code consumed only on new money paths; terminal path already correct) — lost-response retries return the completed payment instead of 400 - MEDIUM: replayRescueLowerBoundSkew widened to 5m (DB-clock-skew stranded originals now rescued) - MEDIUM-1: verifyFamilyAlive DB amplification reduced via 30s bounded family-alive cache; admin route group rate-limited - MEDIUM-3: admin saved-card charges now write admin_audit_log (handlers.go helper + till); [2FA] log line decoupled from user identity - LOW-1: logout scoped to the presented token's family (no cross-session kill) - LOW-2: refresh-reuse grace widened for same-IP replays - LOW-4: squareEnvironmentMismatch enforced for empty env - LOW-5: uuid.ts hard-fails on Math.random fallback (crypto.randomUUID) - Cash/giftcard tip-enabled overflow mirrors the card-terminal carve 26/26 backend packages; 72/72 frontend tests + build; env-docs 41/41.
2987 lines
120 KiB
Go
2987 lines
120 KiB
Go
package payments
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"log/slog"
|
||
"math"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"crussell/clock"
|
||
"crussell/db"
|
||
"crussell/internal/square"
|
||
"crussell/internal/twofa"
|
||
"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"`
|
||
Cancellable bool `json:"cancellable"`
|
||
CancellationReason string `json:"cancellation_reason,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 optional (M1): if not provided, a deterministic key is
|
||
// generated server-side based on user_id + amount + recipient_type + card_id.
|
||
// This ensures retries of the same logical purchase use the same key while
|
||
// distinct purchases get different keys. Max=45 matches Square's limit.
|
||
IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"`
|
||
VerificationToken *string `json:"verification_token,omitempty"`
|
||
// VerificationCode is the customer's current 2FA one-time code (B10): an
|
||
// enforced environment charges/persists a saved card only when this matches
|
||
// the customer's pending code.
|
||
VerificationCode string `json:"verification_code,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, redeemed_by, is_inventory, expiry_date, created_at
|
||
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()
|
||
|
||
// Drain all rows before the per-card cancellation lookups below — pgx.Tx
|
||
// (used by the test harness via a context-stored transaction) does not
|
||
// support concurrent queries on one connection.
|
||
type giftCardRow struct {
|
||
id string
|
||
totalFunds float64
|
||
remaining float64
|
||
redeemedBy sql.NullString
|
||
isInventory bool
|
||
expiryDate sql.NullTime
|
||
createdAt time.Time
|
||
}
|
||
var drained []giftCardRow
|
||
for gcRows.Next() {
|
||
var r giftCardRow
|
||
err = gcRows.Scan(&r.id, &r.totalFunds, &r.remaining, &r.redeemedBy, &r.isInventory, &r.expiryDate, &r.createdAt)
|
||
if err != nil {
|
||
log.Printf("Failed to scan gift card: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
drained = append(drained, r)
|
||
}
|
||
gcRows.Close()
|
||
if err := gcRows.Err(); err != nil {
|
||
log.Printf("Gift card row iteration error: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Surface whether each card is currently cancellable under the 14-day
|
||
// cooling-off right (same assessment the user-facing GetMyGiftCards uses).
|
||
// The purchase transaction is resolved per card so the admin can act on
|
||
// behalf of the card's real purchaser; cards without an online purchase
|
||
// (inventory/till/admin-created) simply report as not cancellable.
|
||
for _, r := range drained {
|
||
var gc GiftCard
|
||
gc.ID = r.id
|
||
gc.TotalFundsAdded = r.totalFunds
|
||
gc.AmountRemaining = r.remaining
|
||
gc.CreatedAt = r.createdAt
|
||
gc.IsInventory = r.isInventory
|
||
if r.redeemedBy.Valid {
|
||
redeemedBy := r.redeemedBy.String
|
||
gc.RedeemedBy = &redeemedBy
|
||
}
|
||
|
||
var purchaserID string
|
||
var purchaseAmount float64
|
||
var purchasedAt time.Time
|
||
err := db.Conn.QueryRow(ctx, `
|
||
SELECT amount, created_at, COALESCE(user_id, '')
|
||
FROM gift_card_transactions
|
||
WHERE gift_card_id = $1 AND transaction_type = 'purchase'
|
||
AND reference_type = 'api'
|
||
ORDER BY created_at DESC
|
||
LIMIT 1`, r.id).Scan(&purchaseAmount, &purchasedAt, &purchaserID)
|
||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||
log.Printf("Failed to load purchase transaction for gift card %s: %v", r.id, err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
status := assessGiftCardCancellation(ctx, db.Conn, r.id, r.totalFunds, r.remaining, r.isInventory, r.expiryDate, purchaseAmount, purchasedAt, purchaserID)
|
||
gc.Cancellable = status.Cancellable
|
||
gc.CancellationReason = status.CancellationReason
|
||
|
||
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
|
||
}
|
||
|
||
// C2: cap the admin-funded amount at £250 (25,000 pence) per transaction
|
||
// (owner decision — tighter than the £10,000 ceiling ValidateAmount
|
||
// enforces on other payment entry points, and matching the till's cap).
|
||
// An inventory card may still be created at £0.
|
||
if int64(math.Round(req.Amount*100)) > maxAdminGiftCardTransactionPence {
|
||
http.Error(w, "Amount exceeds the maximum of £250", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Daily limit (owner decision): an admin may create/top-up/transfer at
|
||
// most £5,000 of gift-card value per UTC day. Enforced before any write.
|
||
adminValueToday, err := adminGiftCardValueToday(ctx, db.Conn, adminID)
|
||
if err != nil {
|
||
log.Printf("Failed to query admin gift-card value today for %s: %v", adminID, err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if int64(math.Round(adminValueToday*100))+int64(math.Round(req.Amount*100)) > maxAdminGiftCardDailyPence {
|
||
http.Error(w, "You have reached your £5,000 daily gift-card value limit", 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"
|
||
}
|
||
// HMRC VAT Notice 700/7: salon-only gift cards are SPV by definition, so
|
||
// the EFFECTIVE type is written even when the stored setting is 'MPV' —
|
||
// recording raw 'MPV' here would make the redemption path defer VAT a
|
||
// second time (VAT is already collected at sale via the GetVATConfig SPV
|
||
// override).
|
||
purchaseVoucherType = effectiveVoucherTypeForPurchase(purchaseVoucherType)
|
||
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
|
||
}
|
||
|
||
// C2: cap the top-up at £250 (25,000 pence) per transaction (owner
|
||
// decision — tighter than the £10,000 ceiling ValidateAmount enforces on
|
||
// other payment entry points, and matching the till's cap).
|
||
if int64(math.Round(req.Amount*100)) > maxAdminGiftCardTransactionPence {
|
||
http.Error(w, "Amount exceeds the maximum of £250", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Daily limit (owner decision): an admin may create/top-up/transfer at
|
||
// most £5,000 of gift-card value per UTC day. Enforced before any write.
|
||
adminValueToday, err := adminGiftCardValueToday(ctx, db.Conn, adminID)
|
||
if err != nil {
|
||
log.Printf("Failed to query admin gift-card value today for %s: %v", adminID, err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if int64(math.Round(adminValueToday*100))+int64(math.Round(req.Amount*100)) > maxAdminGiftCardDailyPence {
|
||
http.Error(w, "You have reached your £5,000 daily gift-card value limit", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
validMethods := map[string]string{
|
||
"cash": "cash",
|
||
"card_machine": "in_person_card",
|
||
"online_square": "online_square",
|
||
"on_the_house": "on_the_house",
|
||
}
|
||
_, ok := validMethods[req.PaymentMethod]
|
||
if !ok {
|
||
http.Error(w, "Invalid payment method. Must be one of: cash, card_machine, online_square, on_the_house", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
tx, err := db.Conn.Begin(ctx)
|
||
if err != nil {
|
||
log.Printf("Failed to begin transaction: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer func() {
|
||
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||
slog.Error("failed to rollback transaction", "err", err)
|
||
}
|
||
}()
|
||
|
||
var redeemedBy sql.NullString
|
||
var isInventory bool
|
||
var currentTotalFunds float64
|
||
err = tx.QueryRow(ctx, "SELECT redeemed_by, is_inventory, total_funds_added FROM gift_cards WHERE id = $1 FOR UPDATE", cardID).Scan(&redeemedBy, &isInventory, ¤tTotalFunds)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
http.Error(w, "Gift card not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
log.Printf("Failed to check gift card: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if redeemedBy.Valid {
|
||
http.Error(w, "Cannot top up a card that has already been redeemed to an account", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
expiryMonths, err := GetGiftCardExpiryMonths(ctx, tx)
|
||
if err != nil {
|
||
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err)
|
||
expiryMonths = defaultGiftCardExpiryMonths
|
||
}
|
||
|
||
txType := "topup"
|
||
var notes *string
|
||
if isInventory && currentTotalFunds == 0 {
|
||
txType = "purchase"
|
||
s := "first top-up on inventory card"
|
||
notes = &s
|
||
}
|
||
|
||
var gc GiftCard
|
||
var createdBy sql.NullString
|
||
err = tx.QueryRow(ctx, `
|
||
UPDATE gift_cards
|
||
SET total_funds_added = total_funds_added + $1,
|
||
amount_remaining = amount_remaining + $1,
|
||
last_used_at = NOW(),
|
||
expiry_date = NOW() + ($3 * INTERVAL '1 month')
|
||
WHERE id = $2
|
||
RETURNING id, total_funds_added, amount_remaining, created_by, created_at
|
||
`, req.Amount, cardID, expiryMonths).Scan(
|
||
&gc.ID,
|
||
&gc.TotalFundsAdded,
|
||
&gc.AmountRemaining,
|
||
&createdBy,
|
||
&gc.CreatedAt,
|
||
)
|
||
if err != nil {
|
||
log.Printf("Failed to top up gift card: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if createdBy.Valid {
|
||
gc.CreatedBy = &createdBy.String
|
||
}
|
||
|
||
_, err = tx.Exec(ctx, `
|
||
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
|
||
VALUES ($1, $2, $3, 'api', NULL, $4, $5)
|
||
`, cardID, txType, req.Amount, adminID, notes)
|
||
if err != nil {
|
||
log.Printf("Failed to record gift card transaction: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if err := tx.Commit(ctx); err != nil {
|
||
log.Printf("Failed to commit transaction: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if err := json.NewEncoder(w).Encode(gc); err != nil {
|
||
log.Printf("Failed to encode JSON response: %v", err)
|
||
}
|
||
}
|
||
|
||
func TransferGiftCard(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
adminID, _ := ctx.Value(mw.UserIDKey).(string)
|
||
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
|
||
}
|
||
|
||
// C2: cap the transfer at £250 (25,000 pence) per transaction (owner
|
||
// decision — tighter than the £10,000 ceiling ValidateAmount enforces on
|
||
// other payment entry points, and matching the till's cap).
|
||
if int64(math.Round(req.Amount*100)) > maxAdminGiftCardTransactionPence {
|
||
http.Error(w, "Amount exceeds the maximum of £250", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Daily limit (owner decision): an admin may create/top-up/transfer at
|
||
// most £5,000 of gift-card value per UTC day. Enforced before any write
|
||
// (and before the advisory lock acquisition below).
|
||
adminValueToday, err := adminGiftCardValueToday(ctx, db.Conn, adminID)
|
||
if err != nil {
|
||
log.Printf("Failed to query admin gift-card value today for %s: %v", adminID, err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if int64(math.Round(adminValueToday*100))+int64(math.Round(req.Amount*100)) > maxAdminGiftCardDailyPence {
|
||
http.Error(w, "You have reached your £5,000 daily gift-card value limit", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Serialize against a concurrent cancellation of the SOURCE card: the
|
||
// gift-card cancel flow (CancelGiftCard) holds this session advisory lock
|
||
// across its eligibility check AND its funding reversal, so a transfer
|
||
// moving value OFF the source mid-cancellation would otherwise return the
|
||
// customer's money while the transferred balance stays live (double value).
|
||
// Taking the same lock here makes transfer-from and cancel mutually
|
||
// exclusive (C2/F2). A transfer INTO a card is covered by the cancel
|
||
// flow's FOR UPDATE re-verification (the destination's balance change makes
|
||
// it ineligible), so only the source needs the lock.
|
||
pinConn, err := db.Conn.Acquire(ctx)
|
||
if err != nil {
|
||
log.Printf("Failed to acquire connection for gift-card transfer lock: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer pinConn.Release()
|
||
lockOK, err := acquireAdvisoryLock(ctx, pinConn, "crussell:giftcard-cancel:"+fromCardID)
|
||
if err != nil {
|
||
log.Printf("Failed to acquire gift-card cancel lock for %s: %v", fromCardID, err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if !lockOK {
|
||
log.Printf("Gift-card transfer lock for %s not acquired within bound — a cancellation is in progress", fromCardID)
|
||
http.Error(w, "This gift card is being processed — please try again in a moment", http.StatusConflict)
|
||
return
|
||
}
|
||
defer releasePaymentLock(pinConn, "crussell:giftcard-cancel:"+fromCardID)
|
||
|
||
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 ---
|
||
|
||
// --- Gift-card redeem failure rate limiting (B16) ---
|
||
//
|
||
// RedeemGiftCard converts a 12-hex gift-card code into account balance. The
|
||
// code space is small enough that an attacker can brute-force plausible codes
|
||
// through repeated redeem attempts; the route-level per-user limiter
|
||
// (mw.RateLimitByUser in main.go) throttles by ACCOUNT, but a distributed probe
|
||
// (many accounts, each trying codes) would still burn a DB lookup per attempt.
|
||
// This in-memory per-CODE counter adds a second layer: after
|
||
// giftCardRedeemFailMax consecutive "invalid code" failures for the same
|
||
// normalized code within giftCardRedeemFailWindow, further redeem attempts for
|
||
// that code are rejected 429 BEFORE any advisory lock or DB work.
|
||
//
|
||
// The counter is keyed on the normalized code, is reset whenever a redeem
|
||
// attempt resolves the code to a REAL card (any found row breaks the streak of
|
||
// invalid-code failures), and entries expire after the window so a one-off
|
||
// typo'd code is never locked out forever. In-memory only (no schema change —
|
||
// matching the A5 refund-failure counter in refunds.go, manualReconcileFailures),
|
||
// best-effort: a process restart clears it, and it is NOT a replacement for the
|
||
// account-level limiter.
|
||
const (
|
||
// giftCardRedeemFailMax is how many consecutive invalid-code redeem
|
||
// failures against the same card code trigger the 429 lockout.
|
||
giftCardRedeemFailMax = 5
|
||
// giftCardRedeemFailWindow is how long a code's failure streak is
|
||
// remembered. A card locked by repeated failures stays locked until the
|
||
// window elapses, then a fresh attempt starts a new streak.
|
||
giftCardRedeemFailWindow = 15 * time.Minute
|
||
// giftCardRedeemFailMaxEntries caps the in-memory map so the counter can
|
||
// never grow unbounded (one entry per code being probed).
|
||
giftCardRedeemFailMaxEntries = 10_000
|
||
)
|
||
|
||
type giftCardRedeemFailState struct {
|
||
count int
|
||
windowEnd time.Time
|
||
}
|
||
|
||
var (
|
||
giftCardRedeemFailMu sync.Mutex
|
||
giftCardRedeemFails = make(map[string]giftCardRedeemFailState)
|
||
)
|
||
|
||
// giftCardRedeemLocked reports whether a code is currently in the 429 lockout
|
||
// (>= giftCardRedeemFailMax consecutive invalid-code failures inside the
|
||
// window). An expired entry is treated as not locked — the window has passed.
|
||
func giftCardRedeemLocked(code string) bool {
|
||
giftCardRedeemFailMu.Lock()
|
||
defer giftCardRedeemFailMu.Unlock()
|
||
st, ok := giftCardRedeemFails[code]
|
||
if !ok || clock.Now().After(st.windowEnd) {
|
||
return false
|
||
}
|
||
return st.count >= giftCardRedeemFailMax
|
||
}
|
||
|
||
// giftCardRedeemFail records one more consecutive invalid-code failure for a
|
||
// code. A fresh streak (new code, or the previous streak expired) starts at 1
|
||
// with a fresh window. Bounded: expired entries are purged on insert and, if
|
||
// the map is still full, one entry is evicted so the map never exceeds
|
||
// giftCardRedeemFailMaxEntries.
|
||
func giftCardRedeemFail(code string) {
|
||
giftCardRedeemFailMu.Lock()
|
||
defer giftCardRedeemFailMu.Unlock()
|
||
now := clock.Now()
|
||
if st, ok := giftCardRedeemFails[code]; ok && !now.After(st.windowEnd) {
|
||
st.count++
|
||
giftCardRedeemFails[code] = st
|
||
return
|
||
}
|
||
if len(giftCardRedeemFails) >= giftCardRedeemFailMaxEntries {
|
||
for c, st := range giftCardRedeemFails {
|
||
if now.After(st.windowEnd) {
|
||
delete(giftCardRedeemFails, c)
|
||
}
|
||
}
|
||
if len(giftCardRedeemFails) >= giftCardRedeemFailMaxEntries {
|
||
for c := range giftCardRedeemFails {
|
||
delete(giftCardRedeemFails, c)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
giftCardRedeemFails[code] = giftCardRedeemFailState{count: 1, windowEnd: now.Add(giftCardRedeemFailWindow)}
|
||
}
|
||
|
||
// giftCardRedeemReset clears a code's invalid-code-failure streak — called
|
||
// whenever a redeem attempt resolves the code to a real gift card, because a
|
||
// successful resolution breaks the consecutive-failure run.
|
||
func giftCardRedeemReset(code string) {
|
||
giftCardRedeemFailMu.Lock()
|
||
delete(giftCardRedeemFails, code)
|
||
giftCardRedeemFailMu.Unlock()
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// B16: a code in the per-card lockout (>= giftCardRedeemFailMax
|
||
// consecutive invalid-code failures inside the window) is rejected 429
|
||
// BEFORE the advisory lock or any DB work — brute-force probing of the
|
||
// 12-hex code space never reaches the database.
|
||
if giftCardRedeemLocked(code) {
|
||
log.Printf("Gift card redeem rate-limited: code %s has failed too many consecutive redeem attempts", code)
|
||
http.Error(w, "Too many failed redeem attempts for this gift card — try again later", http.StatusTooManyRequests)
|
||
return
|
||
}
|
||
|
||
// Serialize against a concurrent cancellation of this same card: the
|
||
// gift-card cancel flow (CancelGiftCard) holds this session advisory lock
|
||
// across its eligibility check AND its funding reversal, so redeeming the
|
||
// balance mid-cancellation would otherwise return the customer's money
|
||
// while the balance stays live (double value). Taking the same lock here
|
||
// makes redeem and cancel mutually exclusive (C2/F2).
|
||
pinConn, err := db.Conn.Acquire(ctx)
|
||
if err != nil {
|
||
log.Printf("Failed to acquire connection for gift-card redeem lock: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer pinConn.Release()
|
||
lockOK, err := acquireAdvisoryLock(ctx, pinConn, "crussell:giftcard-cancel:"+code)
|
||
if err != nil {
|
||
log.Printf("Failed to acquire gift-card cancel lock for %s: %v", code, err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if !lockOK {
|
||
log.Printf("Gift-card redeem lock for %s not acquired within bound — a cancellation is in progress", code)
|
||
http.Error(w, "This gift card is being processed — please try again in a moment", http.StatusConflict)
|
||
return
|
||
}
|
||
defer releasePaymentLock(pinConn, "crussell:giftcard-cancel:"+code)
|
||
|
||
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) {
|
||
// B16: an "invalid code" failure — count it against the per-card
|
||
// lockout so repeated brute-force attempts on the same code get 429
|
||
// after giftCardRedeemFailMax consecutive misses.
|
||
giftCardRedeemFail(code)
|
||
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
|
||
}
|
||
// B16: the code resolved to a real gift card — a found row breaks the
|
||
// consecutive invalid-code-failure streak, so clear the per-card counter.
|
||
giftCardRedeemReset(code)
|
||
|
||
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
|
||
}
|
||
|
||
// M1: idempotency_key is optional. When omitted, a deterministic fallback
|
||
// key is derived server-side AFTER the advisory lock is acquired (see
|
||
// deriveGiftCardIdempotencyKey): the base key (user_id + amount +
|
||
// recipient_type + card_id) is reused while a PENDING payment row exists
|
||
// for the same logical purchase (a lost-response retry lands on the SAME
|
||
// key so Square dedups — the old fresh-random-suffix fallback generated a
|
||
// NEW key per retry, missed the pending row, and charged twice), and it
|
||
// advances deterministically past COMPLETED purchases so two genuinely
|
||
// distinct no-key purchases never collapse onto one dedup key. Clients who
|
||
// need full control still supply their own idempotency_key; that path is
|
||
// unchanged.
|
||
clientSuppliedKey := req.IdempotencyKey != ""
|
||
var noKeyCardPart string
|
||
if !clientSuppliedKey {
|
||
noKeyCardPart = "new"
|
||
if req.CardID != nil && *req.CardID != "" {
|
||
noKeyCardPart = *req.CardID
|
||
}
|
||
}
|
||
if err := validators.Validate.Struct(&req); err != nil {
|
||
log.Printf("Failed to process request: %v", err)
|
||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Product rule (security): only verified accounts may save cards. An
|
||
// unverified/guest/affiliate user may still buy a gift card, but
|
||
// save_card=true is rejected here — before any charge source resolution.
|
||
if rejectSaveCardForUnverified(w, r, req.SaveCard) {
|
||
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 {
|
||
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 !clientSuppliedKey {
|
||
// Identical logical no-key purchases must serialize on the SAME lock
|
||
// key — the deterministic base, not a per-request random suffix — so
|
||
// concurrent lost-response retries cannot both derive a fresh slot and
|
||
// both charge.
|
||
lockKey = fmt.Sprintf("gc-%s-%d-%s-%s", userID, req.Amount, req.RecipientType, noKeyCardPart)
|
||
}
|
||
pinConn, lockOK := acquireBookingPaymentLock(ctx, w, "crussell:giftcard:"+lockKey, "Purchase in progress, try again")
|
||
if !lockOK {
|
||
return
|
||
}
|
||
defer releaseBookingPaymentLock(pinConn, "crussell:giftcard:"+lockKey)
|
||
|
||
// Derive the deterministic fallback key UNDER the lock so the spent-slot
|
||
// scan races no concurrent purchase (mirrors deriveBookingPaymentIdempotencyKey
|
||
// in handlers.go, which derives under the per-booking lock).
|
||
if !clientSuppliedKey {
|
||
derivedKey, dErr := deriveGiftCardIdempotencyKey(ctx, db.Conn, userID, req.Amount, req.RecipientType, noKeyCardPart)
|
||
if dErr != nil {
|
||
log.Printf("Failed to derive gift-card idempotency key: %v", dErr)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
req.IdempotencyKey = derivedKey
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
// A6: CheckIdempotencyByKey matches on the key ALONE (service.go), so a
|
||
// client-supplied deterministic/guessable key (e.g. another user's
|
||
// "gc-<userID>-<amount>-<type>-<cardPart>" fallback key) would resolve
|
||
// to ANOTHER user's payment row — returning it as the caller's completed
|
||
// purchase, reusing it for a charge, or rejecting the caller on it
|
||
// (cross-user hijack). Never reuse or return a row the caller does not
|
||
// own: a foreign match is treated as a fresh request.
|
||
if existing != nil && (existing.CreatedBy == nil || *existing.CreatedBy != userID) {
|
||
log.Printf("Gift card idempotency key %q matched payment row %s (status %s) belonging to a different user — treating as a fresh request", req.IdempotencyKey, existing.ID, existing.Status)
|
||
if clientSuppliedKey {
|
||
// The collided key is occupied (payments.idempotency_key is
|
||
// UNIQUE) — derive a fresh deterministic key for THIS user so
|
||
// the new purchase inserts a new row instead of 500ing on the
|
||
// constraint.
|
||
derivedKey, dErr := deriveGiftCardIdempotencyKey(ctx, db.Conn, userID, req.Amount, req.RecipientType, noKeyCardPart)
|
||
if dErr != nil {
|
||
log.Printf("Failed to derive fresh gift-card idempotency key after foreign-key collision: %v", dErr)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
req.IdempotencyKey = derivedKey
|
||
}
|
||
existing = nil
|
||
}
|
||
if existing != nil {
|
||
if existing.Status == "completed" {
|
||
// RE-VALIDATE the matched purchase payment's refund state before
|
||
// reporting it as success (same guard as the CreateBookingPayment
|
||
// completed-dedup branches): a refunded purchase's money is no
|
||
// longer live, and a same-key retry must not claim the purchase
|
||
// succeeded when the money was already returned.
|
||
if refunded, rErr := paymentHasLiveRefund(ctx, db.Conn, existing.ID); rErr != nil {
|
||
log.Printf("Failed to re-validate gift-card dedup hit %s against refunds: %v", existing.ID, rErr)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
} else if refunded {
|
||
log.Printf("Gift card retry rejected: purchase payment %s (key %q) was refunded — refusing to report a refunded purchase as success", existing.ID, req.IdempotencyKey)
|
||
http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict)
|
||
return
|
||
}
|
||
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
|
||
}
|
||
// Defense-in-depth refund guard on the pending-reuse path too: a
|
||
// pending purchase cannot normally carry a refund (refunds attach
|
||
// to completed charges), but if one ever exists the money is in
|
||
// flight/returned and re-attempting the charge must not proceed.
|
||
if refunded, rErr := paymentHasLiveRefund(ctx, db.Conn, existing.ID); rErr != nil {
|
||
log.Printf("Failed to re-validate pending gift-card reuse %s against refunds: %v", existing.ID, rErr)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
} else if refunded {
|
||
log.Printf("Gift card retry rejected: pending purchase payment %s (key %q) was refunded — refusing to reuse a refunded payment", existing.ID, req.IdempotencyKey)
|
||
http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict)
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
// Daily limit (owner decision): a user may buy at most £500 of online
|
||
// gift cards per UTC day. Sums only COMPLETED online purchases (the
|
||
// gift_card_transactions rows BuyGiftCard writes) so a pending-retry of a
|
||
// failed Square attempt is never blocked by its own un-issued value.
|
||
spentToday, err := userGiftCardSpentToday(ctx, db.Conn, userID)
|
||
if err != nil {
|
||
log.Printf("Failed to query user gift-card spend today for %s: %v", userID, err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if int64(math.Round(spentToday*100))+req.Amount > maxUserGiftCardDailyPence {
|
||
http.Error(w, "You have reached your £500 daily gift-card purchase limit", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// 2FA gating (C5): persisting or charging a card requires 2FA when the
|
||
// feature is enforced — both paying with an existing saved card (CardID)
|
||
// and SAVING a new card during this purchase (SaveCard), mirroring
|
||
// CreateBookingPayment/CreateTipPayment. A one-off new-card (nonce) charge
|
||
// that is not saved is not gated.
|
||
// consume=false (MEDIUM-2): the code is verified here but only NULLed
|
||
// inside the completed-charge transaction below (ConsumePendingCode), so a
|
||
// failed/ambiguous Square charge does NOT burn the operator-relayed code
|
||
// and a same-key retry can re-verify the SAME code.
|
||
if (req.CardID != nil && *req.CardID != "") || req.SaveCard {
|
||
if !requireTwoFactorForCardAccess(w, r, paymentService, userID, req.VerificationCode, false) {
|
||
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.
|
||
// Refresh square_source_id: this attempt may charge a DIFFERENT token
|
||
// than the failed attempt (one-time cnon: nonces are spent), and the
|
||
// sweep replays the charge from the stored source.
|
||
// B6: keep square_request_snapshot's source_id in sync in the SAME
|
||
// statement — the sweep replays the stored snapshot verbatim, and a
|
||
// snapshot carrying the spent source would replay into
|
||
// IDEMPOTENCY_KEY_REUSED, stranding the row pending forever. The
|
||
// at-rest snapshot is AES-256-GCM-encrypted in non-mock deployments
|
||
// (encryptSnapshot's "enc:v1:" marker), so the SourceID refresh must
|
||
// run in Go — decrypt → set SourceID → re-encrypt — instead of the
|
||
// legacy SQL jsonb_set, whose COALESCE(...,'{}')::jsonb cast cannot
|
||
// parse ciphertext. A row with no stored snapshot (legacy) gets just
|
||
// the source column refreshed, mirroring the booking reuse path
|
||
// (handlers.go). Best-effort: any failure leaves the snapshot
|
||
// untouched — the live square_source_id column stays authoritative
|
||
// and the sweep overrides the replay source from it.
|
||
var snap sql.NullString
|
||
if err := tx.QueryRow(ctx, `SELECT square_request_snapshot FROM payments WHERE id = $1`, reusePendingID).Scan(&snap); err != nil {
|
||
log.Printf("Failed to read square_request_snapshot for reused gift-card payment %s: %v", reusePendingID, err)
|
||
}
|
||
var storedSnap *string
|
||
if snap.Valid && snap.String != "" {
|
||
body := []byte(snap.String)
|
||
if !IsExplicitDevOrMockEnv() {
|
||
if dec, dErr := decryptSnapshot(body); dErr != nil {
|
||
log.Printf("Failed to decrypt square_request_snapshot for reused gift-card payment %s: %v", reusePendingID, dErr)
|
||
} else {
|
||
body = dec
|
||
}
|
||
}
|
||
var req square.CreatePaymentReq
|
||
if uErr := json.Unmarshal(body, &req); uErr != nil {
|
||
log.Printf("Failed to parse square_request_snapshot for reused gift-card payment %s: %v", reusePendingID, uErr)
|
||
} else {
|
||
req.SourceID = sourceID
|
||
updated, mErr := json.Marshal(req)
|
||
if mErr != nil {
|
||
log.Printf("Failed to re-marshal square_request_snapshot for reused gift-card payment %s: %v", reusePendingID, mErr)
|
||
} else {
|
||
stored := updated
|
||
if !IsExplicitDevOrMockEnv() {
|
||
if enc, eErr := encryptSnapshot(updated); eErr != nil {
|
||
log.Printf("Failed to encrypt square_request_snapshot for reused gift-card payment %s: %v", reusePendingID, eErr)
|
||
} else {
|
||
stored = enc
|
||
}
|
||
}
|
||
s := string(stored)
|
||
storedSnap = &s
|
||
}
|
||
}
|
||
}
|
||
if storedSnap != nil {
|
||
if _, srcErr := tx.Exec(ctx, `
|
||
UPDATE payments
|
||
SET square_source_id = $1,
|
||
square_request_snapshot = $2
|
||
WHERE id = $3
|
||
`, sourceID, *storedSnap, reusePendingID); srcErr != nil {
|
||
log.Printf("Failed to update square_source_id/square_request_snapshot on reused gift-card payment %s: %v", reusePendingID, srcErr)
|
||
}
|
||
} else {
|
||
if _, srcErr := tx.Exec(ctx, `UPDATE payments SET square_source_id = $1 WHERE id = $2`, sourceID, reusePendingID); srcErr != nil {
|
||
log.Printf("Failed to update square_source_id on reused gift-card payment %s: %v", reusePendingID, srcErr)
|
||
}
|
||
}
|
||
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, square_source_id)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||
RETURNING id
|
||
`, record.PaymentType, record.PaymentMethod, record.Status, record.Amount, record.SquarePaymentID, record.IdempotencyKey, record.Fees, record.UserSavedCardID, record.CreatedBy, record.CreatedAt, record.UpdatedAt, sourceID).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 && vatAppliesToVoucher(vatCfg) {
|
||
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,
|
||
}
|
||
// C3: a card-on-file (ccof) charge — paying with an existing saved card or
|
||
// saving a new card during this purchase — is customer-initiated: Square
|
||
// requires customer_details on stored-credential payments. A one-off cnon:
|
||
// nonce charge is not a stored credential and needs none.
|
||
if req.CardID != nil || req.SaveCard {
|
||
paymentReq.CustomerDetails = &square.CreateCustomerDetails{CustomerInitiated: true}
|
||
}
|
||
|
||
// M1: store the verbatim request JSON so the sweep can replay the charge
|
||
// with an IDENTICAL body under the same key — Square compares the whole
|
||
// request on key reuse, and a reconstructed body returns
|
||
// IDEMPOTENCY_KEY_REUSED, leaving the row pending forever.
|
||
//
|
||
// The write is INTENTIONALLY unconditional (WHERE id = $2, no
|
||
// snapshot-is-null guard like the booking/tip/terminal flows): the reuse
|
||
// branch above (B6) already refreshed square_request_snapshot in the SAME
|
||
// transaction as the square_source_id refresh, and this post-commit write
|
||
// stores the fresh full body for THIS attempt. Both paths converge on a
|
||
// correct snapshot, so a guard would either be dead (first attempt) or
|
||
// wrongly skip this write on the reuse path when the in-tx refresh failed
|
||
// best-effort. Do NOT "fix" this into the guarded form without reworking
|
||
// the reuse-branch refresh.
|
||
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
|
||
log.Printf("Failed to marshal square_request_snapshot for gift-card payment %s: %v", buyPaymentID, mErr)
|
||
} else if stored, eErr := encryptSnapshot(snap); eErr != nil {
|
||
log.Printf("Failed to encrypt square_request_snapshot for gift-card payment %s: %v", buyPaymentID, eErr)
|
||
} else if _, sErr := db.Conn.Exec(ctx, `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2`, string(stored), buyPaymentID); sErr != nil {
|
||
log.Printf("Failed to store square_request_snapshot for gift-card payment %s: %v", buyPaymentID, sErr)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// MEDIUM-2: a saved-card gift-card purchase reached its terminal SUCCESS
|
||
// state — consume the verified 2FA code now, inside the transaction that
|
||
// records the completed charge (the gate verified without consuming, so a
|
||
// failed/ambiguous Square charge did not burn the code and a same-key
|
||
// retry could re-verify the SAME code).
|
||
if (req.CardID != nil && *req.CardID != "") || req.SaveCard {
|
||
if consErr := twofa.ConsumePendingCode(ctx, issueTx, userID); consErr != nil {
|
||
log.Printf("CRITICAL: Square payment succeeded (ID=%s) but consuming the 2FA code for user %s failed: %v — manual reconciliation required", paymentResult.SquarePayID, userID, consErr)
|
||
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"
|
||
}
|
||
// HMRC VAT Notice 700/7: write the EFFECTIVE type (SPV) so
|
||
// voucher_type_at_purchase never records 'MPV' and redemption never
|
||
// applies deferred VAT a second time.
|
||
purchaseVoucherType = effectiveVoucherTypeForPurchase(purchaseVoucherType)
|
||
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"
|
||
}
|
||
// HMRC VAT Notice 700/7: write the EFFECTIVE type (SPV) so
|
||
// voucher_type_at_purchase never records 'MPV' and redemption never
|
||
// applies deferred VAT a second time.
|
||
purchaseVoucherType = effectiveVoucherTypeForPurchase(purchaseVoucherType)
|
||
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 redacted recipient for
|
||
// audit — the full email is third-party PII with no retention coverage
|
||
// and the raw free-text field is a log-injection vector.
|
||
log.Printf("Gift card purchased for friend — value: £%.2f, intended for: %s (code stored in DB, not logged)", amountPounds, redactEmail(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)
|
||
}
|
||
}
|
||
|
||
// deriveGiftCardIdempotencyKey returns the deterministic fallback idempotency
|
||
// key for a no-client-key gift-card purchase:
|
||
// "gc-<userID>-<amount>-<recipientType>-<cardPart>", sha256-truncated when the
|
||
// verbatim form exceeds Square's 45-char limit (the hash stays deterministic).
|
||
//
|
||
// The key must distinguish "same live purchase retried" (dedup) from "new
|
||
// purchase that happens to be identical" (new charge). The candidate is the
|
||
// base key (seq 0) then the base key with a "-<seq>" suffix (seq >= 1) until a
|
||
// slot without a COMPLETED or swept/declined FAILED purchase is found. A
|
||
// COMPLETED purchase always advances the sequence — two genuine identical
|
||
// no-key purchases (e.g. two £20 self cards) are distinct operations and must
|
||
// diverge onto distinct keys (the old random-suffix fallback's collapse fix),
|
||
// and a FAILED purchase (swept stale or definitively rejected) occupies its
|
||
// slot the same way so the customer's identical repurchase diverges onto a
|
||
// fresh key instead of being 409-rejected forever (A10). A PENDING row never
|
||
// occupies a slot: a lost-response retry re-derives the base key, the
|
||
// idempotency lookup below reuses the pending row, and Square's same-key dedup
|
||
// returns the original charge — ONE charge instead of the old double-charge.
|
||
// Must be called under the crussell:giftcard advisory lock so the spent-slot
|
||
// scan races no concurrent purchase (mirrors deriveBookingPaymentIdempotencyKey
|
||
// in handlers.go).
|
||
func deriveGiftCardIdempotencyKey(ctx context.Context, q db.Querier, userID string, amount int64, recipientType, cardPart string) (string, error) {
|
||
baseKey := fmt.Sprintf("gc-%s-%d-%s-%s", userID, amount, recipientType, cardPart)
|
||
for seq := 0; ; seq++ {
|
||
candidate := nextIdempotencyCandidate(baseKey, seq)
|
||
var occupiedID string
|
||
err := q.QueryRow(ctx, `
|
||
SELECT id FROM payments
|
||
WHERE created_by = $1 AND idempotency_key = $2 AND status IN ('completed', 'failed')
|
||
`, userID, candidate).Scan(&occupiedID)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return candidate, nil
|
||
}
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Helpers ---
|
||
|
||
// redactEmail masks an email for logs (PII — third-party addresses are not
|
||
// covered by retention/anonymization, and the raw free-text field is a
|
||
// log-injection vector). Mirrors the dev mock's redaction: first two chars of
|
||
// the local part plus the domain, e.g. "ja***@example.com"; malformed
|
||
// addresses fall back to "[redacted]". The full email stays in the DB row.
|
||
func redactEmail(email string) string {
|
||
at := strings.Index(email, "@")
|
||
if at < 2 || at+1 >= len(email) {
|
||
return "[redacted]"
|
||
}
|
||
return email[:2] + "***@" + email[at+1:]
|
||
}
|
||
|
||
type ExpiredBalance struct {
|
||
ID string `json:"id"`
|
||
AccountID *string `json:"account_id,omitempty"`
|
||
OriginalBalance float64 `json:"original_balance"`
|
||
ExpiredAt time.Time `json:"expired_at"`
|
||
ClaimedAt *time.Time `json:"claimed_at,omitempty"`
|
||
ClaimedByAdmin *string `json:"claimed_by_admin,omitempty"`
|
||
Notes *string `json:"notes,omitempty"`
|
||
}
|
||
|
||
func GetExpiredBalances(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
|
||
rows, err := db.Conn.Query(ctx, `
|
||
SELECT id, account_id, original_balance, expired_at, claimed_at, claimed_by_admin, notes
|
||
FROM gift_card_expired_balances
|
||
ORDER BY expired_at DESC
|
||
`)
|
||
if err != nil {
|
||
log.Printf("Failed to query expired balances: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
var balances []ExpiredBalance
|
||
for rows.Next() {
|
||
var b ExpiredBalance
|
||
var accountID, claimedByAdmin, notes sql.NullString
|
||
var claimedAt sql.NullTime
|
||
|
||
err = rows.Scan(&b.ID, &accountID, &b.OriginalBalance, &b.ExpiredAt, &claimedAt, &claimedByAdmin, ¬es)
|
||
if err != nil {
|
||
log.Printf("Failed to scan expired balance: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if accountID.Valid {
|
||
b.AccountID = &accountID.String
|
||
}
|
||
if claimedAt.Valid {
|
||
b.ClaimedAt = &claimedAt.Time
|
||
}
|
||
if claimedByAdmin.Valid {
|
||
b.ClaimedByAdmin = &claimedByAdmin.String
|
||
}
|
||
if notes.Valid {
|
||
b.Notes = ¬es.String
|
||
}
|
||
|
||
balances = append(balances, b)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
log.Printf("Row iteration error: %v", err)
|
||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if balances == nil {
|
||
balances = []ExpiredBalance{}
|
||
}
|
||
|
||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||
"expired_balances": balances,
|
||
"total": len(balances),
|
||
}); err != nil {
|
||
log.Printf("Failed to encode JSON response: %v", err)
|
||
}
|
||
}
|
||
|
||
type ClaimExpiredBalanceRequest struct {
|
||
BalanceID string `json:"balance_id"`
|
||
Notes *string `json:"notes,omitempty"`
|
||
}
|
||
|
||
func ClaimExpiredBalance(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
adminID, ok := ctx.Value(mw.UserIDKey).(string)
|
||
if !ok || adminID == "" {
|
||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
var req ClaimExpiredBalanceRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if req.BalanceID == "" {
|
||
http.Error(w, "balance_id is required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
tx, err := db.Conn.Begin(ctx)
|
||
if err != nil {
|
||
log.Printf("Failed to begin transaction: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer func() {
|
||
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||
slog.Error("failed to rollback transaction", "err", err)
|
||
}
|
||
}()
|
||
|
||
var existingClaimedAt sql.NullTime
|
||
err = tx.QueryRow(ctx, `
|
||
SELECT claimed_at FROM gift_card_expired_balances WHERE id = $1 FOR UPDATE
|
||
`, req.BalanceID).Scan(&existingClaimedAt)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
http.Error(w, "Expired balance not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
log.Printf("Failed to check expired balance: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if existingClaimedAt.Valid {
|
||
http.Error(w, "Balance already claimed", http.StatusConflict)
|
||
return
|
||
}
|
||
|
||
_, err = tx.Exec(ctx, `
|
||
UPDATE gift_card_expired_balances
|
||
SET claimed_at = NOW(), claimed_by_admin = $1, notes = $2
|
||
WHERE id = $3
|
||
`, adminID, req.Notes, req.BalanceID)
|
||
if err != nil {
|
||
log.Printf("Failed to claim expired balance: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if err := tx.Commit(ctx); err != nil {
|
||
log.Printf("Failed to commit transaction: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.WriteHeader(http.StatusOK)
|
||
if err := json.NewEncoder(w).Encode(map[string]string{"status": "claimed"}); err != nil {
|
||
log.Printf("Failed to encode JSON response: %v", err)
|
||
}
|
||
}
|
||
|
||
// --- 14-day cooling-off cancellation (C3) ---
|
||
//
|
||
// The Gift Card T&C is a binding UK consumer contract that grants a 14-day
|
||
// right to cancel online gift-card purchases with a refund to the original
|
||
// payment method (Consumer Contracts (Information, Cancellation and Additional
|
||
// Charges) Regulations 2013). Until this endpoint existed there was NO code
|
||
// path to execute that right: RefundPayment explicitly rejects
|
||
// gift-card-purchase payments (booking_id IS NULL) and no cancel route/UI
|
||
// existed — a legal exposure.
|
||
//
|
||
// Eligibility signal for "purchased online by this user within 14 days":
|
||
// - A gift_card_transactions row on the card with transaction_type='purchase',
|
||
// reference_type='api' and user_id = the authenticated caller. BuyGiftCard
|
||
// (the only customer-facing online purchase path) writes exactly this.
|
||
// Till/admin sales can NEVER match: till sales write reference_type
|
||
// 'till_sale' (see till.go) and admin-created cards (CreateGiftCard) write
|
||
// reference_type='api' but with the ADMIN's user id, never the caller's.
|
||
// - The card itself must be unredeemed (redeemed_by IS NULL — self-purchases
|
||
// auto-redeem and are therefore not cancellable), non-inventory, not
|
||
// expired, and still hold exactly its original purchase value
|
||
// (total_funds_added == amount_remaining == purchase amount — a top-up,
|
||
// transfer, or spend breaks this).
|
||
// - The originating payments row: booking_id IS NULL (the same discriminator
|
||
// RefundPayment and the stale-pending sweep use for gift-card purchases),
|
||
// created_by = the buyer, payment_method='online_square', status='completed',
|
||
// with a square_payment_id — the target of the refund.
|
||
|
||
// giftCardCoolingOffPeriod is the 14-day statutory cancellation window
|
||
// (Consumer Contracts (Information, Cancellation and Additional Charges)
|
||
// Regulations 2013, regs. 29-38).
|
||
const giftCardCoolingOffPeriod = 14 * 24 * time.Hour
|
||
|
||
// giftCardCancelRefundReason is the Square refund reason. Square's
|
||
// refund-reason limit is 192 chars; this is comfortably under it.
|
||
const giftCardCancelRefundReason = "Gift card cancelled within 14-day cooling-off period (Consumer Contracts Regulations 2013)"
|
||
|
||
type CancelGiftCardRequest struct {
|
||
Code string `json:"code"`
|
||
// PaymentID optionally identifies the originating purchase payment so the
|
||
// handler skips the amount/timing match. When omitted the purchase payment
|
||
// is located from the gift-card purchase transaction.
|
||
PaymentID string `json:"payment_id,omitempty"`
|
||
}
|
||
|
||
// MyGiftCard is one of the caller's online-purchased (unredeemed) gift cards,
|
||
// with the expiry date (T&C: "the expiry date is displayed in your account")
|
||
// and whether the 14-day cancellation right currently applies.
|
||
type MyGiftCard struct {
|
||
Code string `json:"code"`
|
||
Amount float64 `json:"amount"`
|
||
PurchasedAt time.Time `json:"purchased_at"`
|
||
ExpiryDate *time.Time `json:"expiry_date,omitempty"`
|
||
Cancellable bool `json:"cancellable"`
|
||
CancellationReason string `json:"cancellation_reason,omitempty"`
|
||
PaymentID string `json:"payment_id,omitempty"`
|
||
}
|
||
|
||
type MyGiftCardsResponse struct {
|
||
GiftCards []MyGiftCard `json:"gift_cards"`
|
||
}
|
||
|
||
// GetMyGiftCards lists the authenticated user's customer-facing online
|
||
// gift-card purchases that are still held as cards (unredeemed — a "self"
|
||
// purchase is auto-redeemed into the pooled balance and so never appears
|
||
// here). Each entry carries the rolling expiry date and whether the 14-day
|
||
// cooling-off cancellation right applies, so the account page can surface a
|
||
// "Cancel & refund" action exactly where the consumer is legally entitled to
|
||
// one.
|
||
func GetMyGiftCards(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
|
||
}
|
||
|
||
// Only gift_card_transactions rows created by BuyGiftCard match
|
||
// (reference_type='api' + user_id = caller); till sales ('till_sale') and
|
||
// admin-created cards (user_id = admin) never appear.
|
||
rows, err := db.Conn.Query(ctx, `
|
||
SELECT gc.id, gc.total_funds_added, gc.amount_remaining, gc.is_inventory,
|
||
gc.expiry_date, gct.amount AS purchase_amount, gct.created_at AS purchased_at
|
||
FROM gift_card_transactions gct
|
||
JOIN gift_cards gc ON gc.id = gct.gift_card_id
|
||
WHERE gct.user_id = $1 AND gct.transaction_type = 'purchase'
|
||
AND gct.reference_type = 'api'
|
||
AND gc.redeemed_by IS NULL
|
||
ORDER BY gct.created_at DESC
|
||
`, userID)
|
||
if err != nil {
|
||
log.Printf("Failed to query user gift cards: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Drain all rows before the per-card payment/refund lookups below —
|
||
// pgx.Tx (used by the test harness via a context-stored transaction) does
|
||
// not support concurrent queries on one connection.
|
||
type giftCardRow struct {
|
||
code string
|
||
totalFunds float64
|
||
remaining float64
|
||
isInventory bool
|
||
expiryDate sql.NullTime
|
||
purchaseAmount float64
|
||
purchasedAt time.Time
|
||
}
|
||
var drained []giftCardRow
|
||
for rows.Next() {
|
||
var r giftCardRow
|
||
if err := rows.Scan(&r.code, &r.totalFunds, &r.remaining, &r.isInventory, &r.expiryDate, &r.purchaseAmount, &r.purchasedAt); err != nil {
|
||
log.Printf("Failed to scan user gift card: %v", err)
|
||
continue
|
||
}
|
||
drained = append(drained, r)
|
||
}
|
||
rows.Close()
|
||
if err := rows.Err(); err != nil {
|
||
log.Printf("User gift card row iteration error: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
out := []MyGiftCard{}
|
||
for _, r := range drained {
|
||
gc := MyGiftCard{
|
||
Code: r.code,
|
||
Amount: r.purchaseAmount,
|
||
PurchasedAt: r.purchasedAt,
|
||
}
|
||
if r.expiryDate.Valid {
|
||
expiry := r.expiryDate.Time
|
||
gc.ExpiryDate = &expiry
|
||
}
|
||
|
||
status := assessGiftCardCancellation(ctx, db.Conn, r.code, r.totalFunds, r.remaining, r.isInventory, r.expiryDate, r.purchaseAmount, r.purchasedAt, userID)
|
||
gc.Cancellable = status.Cancellable
|
||
gc.CancellationReason = status.CancellationReason
|
||
gc.PaymentID = status.PaymentID
|
||
out = append(out, gc)
|
||
}
|
||
if out == nil {
|
||
out = []MyGiftCard{}
|
||
}
|
||
|
||
if err := json.NewEncoder(w).Encode(MyGiftCardsResponse{GiftCards: out}); err != nil {
|
||
log.Printf("Failed to encode JSON response: %v", err)
|
||
}
|
||
}
|
||
|
||
// giftCardCancellationStatus captures whether a gift card is currently
|
||
// cancellable under the 14-day cooling-off right, and why not when it is not.
|
||
type giftCardCancellationStatus struct {
|
||
Cancellable bool
|
||
CancellationReason string
|
||
PaymentID string // the originating purchase payment, when cancellable
|
||
}
|
||
|
||
// assessGiftCardCancellation evaluates a single gift card's cancellation
|
||
// eligibility under the 14-day cooling-off rules (CCR 2013 regs. 29-38) using
|
||
// the EXACT same signal set and reason strings as the user-facing
|
||
// GetMyGiftCards list, so the admin list can surface the same state.
|
||
//
|
||
// purchaserID is the owner of the card's online purchase transaction
|
||
// (transaction_type='purchase', reference_type='api'); empty means the card
|
||
// was never purchased online (inventory/till/admin-created), so it can never
|
||
// be cancellable. The caller must have drained all open rows before calling —
|
||
// q must not be an open rows cursor (pgx.Tx cannot run a second query on the
|
||
// same connection).
|
||
func assessGiftCardCancellation(ctx context.Context, q db.Querier, code string, totalFunds, remaining float64, isInventory bool, expiryDate sql.NullTime, purchaseAmount float64, purchasedAt time.Time, purchaserID string) giftCardCancellationStatus {
|
||
var st giftCardCancellationStatus
|
||
|
||
if purchaserID == "" {
|
||
st.CancellationReason = "This gift card was not purchased online, so it cannot be cancelled"
|
||
return st
|
||
}
|
||
|
||
// A partially spent card is only cancellable when its shortfall is
|
||
// verified till spend (reg 34(9) refunds the UNSPENT balance); top-ups
|
||
// and unaccounted shortfalls (transfers/clawbacks) are not.
|
||
partialSpendOK := true
|
||
if !approxEqual(totalFunds, purchaseAmount) {
|
||
partialSpendOK = false
|
||
} else if !approxEqual(remaining, purchaseAmount) {
|
||
if remaining <= 0 {
|
||
partialSpendOK = false
|
||
} else {
|
||
spentAtTill, serr := giftCardSpendAtTill(ctx, q, code)
|
||
if serr != nil {
|
||
log.Printf("Failed to verify till spend for gift card %s: %v", code, serr)
|
||
partialSpendOK = false
|
||
} else {
|
||
partialSpendOK = approxEqual(spentAtTill+remaining, purchaseAmount)
|
||
}
|
||
}
|
||
}
|
||
|
||
switch {
|
||
case isInventory:
|
||
st.CancellationReason = "This card was created as shop stock, not purchased online"
|
||
case !approxEqual(totalFunds, purchaseAmount):
|
||
st.CancellationReason = "This gift card has been topped up, transferred, or otherwise altered"
|
||
case !approxEqual(remaining, purchaseAmount) && !partialSpendOK:
|
||
if remaining <= 0 {
|
||
st.CancellationReason = "This gift card has no remaining balance to refund"
|
||
} else {
|
||
st.CancellationReason = "This gift card has been topped up, transferred, or partially spent in a way that cannot be verified"
|
||
}
|
||
case expiryDate.Valid && expiryDate.Time.Before(clock.Now()):
|
||
st.CancellationReason = "This gift card has expired"
|
||
case purchasedAt.Before(clock.Now().Add(-giftCardCoolingOffPeriod)):
|
||
st.CancellationReason = "The 14-day cancellation period has expired"
|
||
default:
|
||
paymentID, _, paymentOK, perr := findGiftCardPurchasePayment(ctx, q, purchaserID, "", purchaseAmount, purchasedAt)
|
||
if perr != nil {
|
||
log.Printf("Failed to locate purchase payment for gift card %s: %v", code, perr)
|
||
st.CancellationReason = "The original purchase payment could not be verified"
|
||
} else if !paymentOK {
|
||
st.CancellationReason = "The original purchase payment could not be found"
|
||
} else {
|
||
// A completed/pending refund row means the money already
|
||
// returned (or is in flight) — the card cannot be cancelled a
|
||
// second time.
|
||
var refundStatus string
|
||
err := q.QueryRow(ctx, `
|
||
SELECT status FROM refunds
|
||
WHERE payment_id = $1 AND status IN ('completed', 'pending')
|
||
LIMIT 1`, paymentID).Scan(&refundStatus)
|
||
switch {
|
||
case err == nil:
|
||
st.CancellationReason = "This gift card has already been refunded"
|
||
case errors.Is(err, pgx.ErrNoRows):
|
||
st.Cancellable = true
|
||
st.PaymentID = paymentID
|
||
default:
|
||
log.Printf("Failed to check refund status for payment %s: %v", paymentID, err)
|
||
st.CancellationReason = "Unable to verify the refund status of this gift card"
|
||
}
|
||
}
|
||
}
|
||
return st
|
||
}
|
||
|
||
// CancelGiftCard resolves the authenticated caller as the cancellation actor
|
||
// and delegates to the shared cancellation core (see cancelGiftCardForUser for
|
||
// the full money-safety contract).
|
||
func CancelGiftCard(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 CancelGiftCardRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
cancelGiftCardForUser(ctx, w, r, userID, req)
|
||
}
|
||
|
||
// AdminCancelGiftCard is the admin-side cancellation surface for the 14-day
|
||
// cooling-off right: it lets a partially spent online-purchased card be
|
||
// managed from the admin Gift Card Management screen. The admin route group
|
||
// (mw.RequireAdmin in main.go) guarantees the role; the admin's user id is the
|
||
// ACTOR (recorded on the refunds row and the 'cancelled' audit transaction),
|
||
// while the cancellation core still verifies the card's purchase transaction
|
||
// against its ACTUAL purchaser, so an admin can act on behalf of the real
|
||
// owner without weakening the money-safety invariants.
|
||
func AdminCancelGiftCard(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
adminID, ok := ctx.Value(mw.UserIDKey).(string)
|
||
if !ok || adminID == "" {
|
||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
if !isAdminRequest(r) {
|
||
http.Error(w, "Admin access required", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
var req CancelGiftCardRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
cancelGiftCardForUser(ctx, w, r, adminID, req)
|
||
}
|
||
|
||
// cancelGiftCardForUser executes the statutory 14-day right to cancel an
|
||
// online gift-card purchase: it issues a Square refund to the original payment
|
||
// method — the FULL purchase value for an unspent card, or the UNSPENT
|
||
// remainder for a card whose shortfall is verified till spend (reg 34(9)) —
|
||
// then reverses the gift-card funding (amount_remaining zeroed and the
|
||
// card expired so the balance can never be spent). Registered under
|
||
// mw.RequireAuth + mw.RequireNonGuest (guests cannot buy gift cards); the
|
||
// admin surface (AdminCancelGiftCard) reuses this same core with the admin as
|
||
// the actor.
|
||
//
|
||
// Failure safety: the refunds row is inserted (pending) BEFORE the Square call
|
||
// with a deterministic idempotency key; if Square fails, the row stays pending
|
||
// for a same-key retry (Square dedups) and the gift card is NOT touched — the
|
||
// customer keeps the card and the request returns 5xx for a retry. Only once
|
||
// Square accepts the refund is the card reversed, so a refund can never be
|
||
// issued without the card value being neutralised, and the card can never be
|
||
// cancelled while the money is still with the salon.
|
||
func cancelGiftCardForUser(ctx context.Context, w http.ResponseWriter, r *http.Request, userID string, req CancelGiftCardRequest) {
|
||
code := validators.NormalizeGiftCardCode(req.Code)
|
||
if !validators.IsValidID(code) {
|
||
http.Error(w, "Invalid gift card code format", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if req.PaymentID != "" && !validators.IsValidID(req.PaymentID) {
|
||
http.Error(w, "Invalid payment id", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Serialize cancellation attempts per gift card (bounded try-lock,
|
||
// mirroring the RefundPayment handler) so two concurrent cancels of the
|
||
// same card cannot both pass the eligibility + refunds-row guards.
|
||
pinConn, err := db.Conn.Acquire(ctx)
|
||
if err != nil {
|
||
log.Printf("Failed to acquire connection for gift-card cancel lock: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer pinConn.Release()
|
||
lockOK, err := acquireAdvisoryLock(ctx, pinConn, "crussell:giftcard-cancel:"+code)
|
||
if err != nil {
|
||
log.Printf("Failed to acquire gift-card cancel lock for %s: %v", code, err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if !lockOK {
|
||
log.Printf("Gift-card cancel lock for %s not acquired within bound — a cancellation is already in progress", code)
|
||
http.Error(w, "A gift-card cancellation is already in progress, try again", http.StatusConflict)
|
||
return
|
||
}
|
||
defer releasePaymentLock(pinConn, "crussell:giftcard-cancel:"+code)
|
||
|
||
tx, err := db.Conn.Begin(ctx)
|
||
if err != nil {
|
||
log.Printf("Failed to begin gift-card cancel 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 gift-card cancel transaction", "err", err)
|
||
}
|
||
}()
|
||
|
||
// Lock the card FOR UPDATE so concurrent cancels / top-ups / transfers
|
||
// serialize on the eligibility read below.
|
||
var totalFunds, remaining float64
|
||
var redeemedBy sql.NullString
|
||
var isInventory bool
|
||
var expiryDate sql.NullTime
|
||
err = tx.QueryRow(ctx, `
|
||
SELECT total_funds_added, amount_remaining, redeemed_by, is_inventory, expiry_date
|
||
FROM gift_cards WHERE id = $1 FOR UPDATE`, code).
|
||
Scan(&totalFunds, &remaining, &redeemedBy, &isInventory, &expiryDate)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
http.Error(w, "Gift card not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
log.Printf("Failed to load gift card %s for cancellation: %v", code, err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// --- Eligibility (C3) ---
|
||
if isInventory {
|
||
http.Error(w, "This gift card was created as shop stock, not purchased online — it cannot be cancelled", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if redeemedBy.Valid {
|
||
http.Error(w, "This gift card has already been redeemed to an account balance and cannot be cancelled", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// The card must be traceable to a customer-facing online purchase (see the
|
||
// signal documentation above). The purchase row is resolved WITHOUT
|
||
// filtering on the actor's user id so the card's TRUE purchaser is found:
|
||
// in the user flow the actor must BE the purchaser (enforced below), while
|
||
// an admin acts on behalf of the card's real owner.
|
||
var purchaseAmount float64
|
||
var purchasedAt time.Time
|
||
var purchaserID string
|
||
err = tx.QueryRow(ctx, `
|
||
SELECT amount, created_at, COALESCE(user_id, '')
|
||
FROM gift_card_transactions
|
||
WHERE gift_card_id = $1 AND transaction_type = 'purchase'
|
||
AND reference_type = 'api'
|
||
ORDER BY created_at DESC
|
||
LIMIT 1`, code).Scan(&purchaseAmount, &purchasedAt, &purchaserID)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
if isAdminRequest(r) {
|
||
http.Error(w, "This gift card was not purchased online, so it cannot be cancelled", http.StatusBadRequest)
|
||
} else {
|
||
http.Error(w, "This gift card was not purchased online by your account, so it cannot be cancelled here", http.StatusBadRequest)
|
||
}
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("Failed to load gift-card purchase transaction for %s: %v", code, err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if purchaserID != userID && !isAdminRequest(r) {
|
||
http.Error(w, "This gift card was not purchased online by your account, so it cannot be cancelled here", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if purchasedAt.Before(clock.Now().Add(-giftCardCoolingOffPeriod)) {
|
||
http.Error(w, "The 14-day cancellation period for this gift card has expired", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Eligibility (C3): a full-refund cancellation is only valid while the
|
||
// card still holds exactly its original purchase value. A partially spent
|
||
// card remains cancellable when the shortfall is verified till spend —
|
||
// reg 34(9) entitles the consumer to the UNSPENT balance back while the
|
||
// value already consumed on services stays with the salon. Top-ups,
|
||
// redeemed cards, and unaccounted shortfalls (transfers/clawbacks) are
|
||
// rejected outright.
|
||
refundAmount := purchaseAmount
|
||
partialRefund := false
|
||
var spentAtTill float64
|
||
if !approxEqual(totalFunds, purchaseAmount) {
|
||
http.Error(w, "This gift card has been topped up, transferred, or partially spent and can no longer be cancelled for a full refund", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if !approxEqual(remaining, purchaseAmount) {
|
||
if remaining <= 0 {
|
||
http.Error(w, "This gift card has no remaining balance to refund", http.StatusBadRequest)
|
||
return
|
||
}
|
||
spentAtTill, err = giftCardSpendAtTill(ctx, tx, code)
|
||
if err != nil {
|
||
log.Printf("Failed to verify till spend for gift card %s: %v", code, err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if !approxEqual(spentAtTill+remaining, purchaseAmount) {
|
||
http.Error(w, "This gift card has been partially spent, transferred, or otherwise altered and can no longer be cancelled; the remaining balance can be spent or redeemed to your account balance", http.StatusBadRequest)
|
||
return
|
||
}
|
||
refundAmount = remaining
|
||
partialRefund = true
|
||
}
|
||
|
||
if expiryDate.Valid && expiryDate.Time.Before(clock.Now()) {
|
||
http.Error(w, "This gift card has already expired and cannot be cancelled", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// --- Locate the originating purchase payment (Square charge) ---
|
||
// The payment is matched against the card's TRUE purchaser, never the
|
||
// actor: for the user flow purchaserID == userID (enforced above), and for
|
||
// an admin cancellation the card's owner is used.
|
||
paymentID, squarePaymentID, paymentOK, perr := findGiftCardPurchasePayment(ctx, tx, purchaserID, req.PaymentID, purchaseAmount, purchasedAt)
|
||
if perr != nil {
|
||
log.Printf("Failed to locate purchase payment for gift card %s: %v", code, perr)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if !paymentOK {
|
||
http.Error(w, "The original purchase payment for this gift card could not be found, so it cannot be refunded", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
refundKey := paymentID + "-gccancel-" + strconv.FormatInt(int64(math.Round(refundAmount*100)), 10)
|
||
refundID := ""
|
||
resumingRefund := false
|
||
var priorRefundID, priorStatus, priorKey string
|
||
var priorAmount float64
|
||
err = tx.QueryRow(ctx, `
|
||
SELECT id, status, amount, COALESCE(idempotency_key, '')
|
||
FROM refunds WHERE payment_id = $1
|
||
ORDER BY created_at DESC, id DESC LIMIT 1`, paymentID).
|
||
Scan(&priorRefundID, &priorStatus, &priorAmount, &priorKey)
|
||
switch {
|
||
case errors.Is(err, pgx.ErrNoRows):
|
||
// Fresh cancellation — insert the pending refund row below.
|
||
case err != nil:
|
||
log.Printf("Failed to check prior refund for payment %s: %v", paymentID, err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
default:
|
||
switch priorStatus {
|
||
case "completed":
|
||
if priorAmount >= refundAmount-0.005 {
|
||
// The full cancellation entitlement (the full purchase value,
|
||
// or the unspent remainder of a partially spent card) was
|
||
// already refunded. Neutralise the card so the value cannot
|
||
// be spent on top of the returned money, then report.
|
||
if remaining > 0 {
|
||
if cerr := cancelGiftCardFunding(ctx, tx, code, userID, priorRefundID, refundAmount); cerr != nil {
|
||
log.Printf("CRITICAL: gift card %s was already refunded but funding reversal failed: %v — MANUAL RECONCILIATION REQUIRED", code, cerr)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
if cerr := tx.Commit(ctx); cerr != nil {
|
||
log.Printf("Failed to commit gift-card cancel transaction: %v", cerr)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusOK)
|
||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||
"status": "success",
|
||
"message": "This gift card was already refunded and has now been cancelled.",
|
||
"amount_refunded": refundAmount,
|
||
}); err != nil {
|
||
log.Printf("Failed to encode JSON response: %v", err)
|
||
}
|
||
return
|
||
}
|
||
// Partial prior refund (C6/F8): the card still holds its full
|
||
// cancellation entitlement (the full purchase value, or the unspent
|
||
// remainder of a partially spent card) but only part of it was
|
||
// returned. Refund the DIFFERENCE via Square with a fresh
|
||
// deterministic key before neutralising, so the un-refunded remainder
|
||
// is never silently swallowed.
|
||
refundAmount = refundAmount - priorAmount
|
||
refundKey = paymentID + "-gccancel-diff-" + strconv.FormatInt(int64(math.Round(refundAmount*100)), 10)
|
||
log.Printf("Gift-card purchase %s has a prior partial refund of £%.2f — issuing the £%.2f remainder", paymentID, priorAmount, refundAmount)
|
||
case "pending":
|
||
// In-flight refund for this payment. Only resume if the pending
|
||
// row is OURS; a foreign pending row must not be re-issued.
|
||
if !strings.HasPrefix(priorKey, paymentID+"-gccancel-") {
|
||
log.Printf("Gift-card cancel rejected: payment %s has a pending refund row %s from another flow", paymentID, priorRefundID)
|
||
http.Error(w, "A refund for this gift card purchase is already being processed — please try again later", http.StatusConflict)
|
||
return
|
||
}
|
||
refundID = priorRefundID
|
||
refundKey = priorKey
|
||
refundAmount = priorAmount
|
||
resumingRefund = true
|
||
case "failed":
|
||
// Money provably never moved — safe to re-issue with the stored
|
||
// key (or persist the deterministic key when a legacy row has
|
||
// none, mirroring ensureRefundKey).
|
||
refundID = priorRefundID
|
||
refundAmount = priorAmount
|
||
resumingRefund = true
|
||
if priorKey != "" {
|
||
refundKey = priorKey
|
||
} else {
|
||
if _, uErr := tx.Exec(ctx, `
|
||
UPDATE refunds SET idempotency_key = $1
|
||
WHERE id = $2 AND idempotency_key IS NULL`, refundKey, refundID); uErr != nil {
|
||
log.Printf("Failed to persist refund idempotency key for %s: %v", refundID, uErr)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if refundID == "" {
|
||
// Insert the pending refund row. booking_id stays NULL for
|
||
// gift-card purchases (the payments row has no booking).
|
||
err = tx.QueryRow(ctx, `
|
||
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_by, created_at, origin)
|
||
VALUES ($1, NULL, $2, 'pending', $3, $4, $5, $6, 'giftcard_cancel')
|
||
ON CONFLICT (idempotency_key) DO NOTHING
|
||
RETURNING id`,
|
||
paymentID, refundAmount, giftCardCancelRefundReason, refundKey, userID, clock.Now()).Scan(&refundID)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
// A same-key row exists from a concurrent attempt (unreachable
|
||
// under the advisory lock, but stay safe): treat it as ours.
|
||
if rErr := tx.QueryRow(ctx, `SELECT id FROM refunds WHERE idempotency_key = $1`, refundKey).Scan(&refundID); rErr != nil {
|
||
log.Printf("Failed to re-read concurrent refund row for key %s: %v", refundKey, rErr)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
} else if err != nil {
|
||
log.Printf("Failed to insert gift-card cancel refund record: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
|
||
if err := tx.Commit(ctx); err != nil {
|
||
log.Printf("Failed to commit gift-card cancel transaction: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// resolveRefundRow updates the committed refunds row to a definitive
|
||
// outcome on the FAILURE paths below, where the reversal transaction has
|
||
// already been rolled back (no money moved / declined / ambiguous). On the
|
||
// success paths the row is instead resolved INSIDE the reversal
|
||
// transaction, so 'completed' commits atomically with the funding
|
||
// reversal (C2/F4) — a completed refund can never coexist with a live
|
||
// card.
|
||
resolveRefundRow := func(status, sqRefundID string) {
|
||
_, upErr := db.Conn.Exec(ctx, `UPDATE refunds SET status = $1, square_refund_id = $2 WHERE id = $3`, status, sqRefundID, refundID)
|
||
if upErr != nil {
|
||
slog.Error("CRITICAL: Square refund resolved but refund row update failed — manual reconciliation required", "refund_id", refundID, "err", upErr)
|
||
}
|
||
}
|
||
|
||
// --- Reversal + refund transaction (C2/F2, C2/F4) ---
|
||
// The eligibility transaction above committed with the card still holding
|
||
// its full purchase value. Between that commit and the reversal the card
|
||
// must not be redeemable or transferable — the crussell:giftcard-cancel
|
||
// advisory lock (held for this whole handler, and now also taken by
|
||
// RedeemGiftCard and TransferGiftCard) serializes those operations against
|
||
// this cancellation. The reversal transaction below re-verifies under
|
||
// FOR UPDATE as a second line of defence, and the Square refund is issued
|
||
// only AFTER that re-verification passes.
|
||
rtx, rerr := db.Conn.Begin(ctx)
|
||
if rerr != nil {
|
||
slog.Error("CRITICAL: no transaction available to re-verify and reverse gift card", "gift_card_id", code, "refund_id", refundID, "err", rerr)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer func() {
|
||
if err := rtx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||
slog.Error("failed to rollback gift-card reversal transaction", "err", err)
|
||
}
|
||
}()
|
||
|
||
var revTotalFunds, revRemaining float64
|
||
var revRedeemedBy sql.NullString
|
||
var revIsInventory bool
|
||
var revExpiry sql.NullTime
|
||
err = rtx.QueryRow(ctx, `
|
||
SELECT total_funds_added, amount_remaining, redeemed_by, is_inventory, expiry_date
|
||
FROM gift_cards WHERE id = $1 FOR UPDATE`, code).
|
||
Scan(&revTotalFunds, &revRemaining, &revRedeemedBy, &revIsInventory, &revExpiry)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
http.Error(w, "Gift card not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
log.Printf("Failed to re-load gift card %s for cancellation reversal: %v", code, err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// A partially spent card must re-verify against the SAME remaining value
|
||
// that was spend-verified and funded at eligibility; a full-value card
|
||
// re-verifies against the full purchase amount.
|
||
expectRemaining := purchaseAmount
|
||
if partialRefund {
|
||
expectRemaining = remaining
|
||
}
|
||
if revIsInventory || revRedeemedBy.Valid || !approxEqual(revTotalFunds, purchaseAmount) || !approxEqual(revRemaining, expectRemaining) || (revExpiry.Valid && revExpiry.Time.Before(clock.Now())) {
|
||
// The card changed between the eligibility commit and the reversal —
|
||
// redeemed, spent, transferred, or expired. Do NOT refund on top of
|
||
// the live balance (double value). The reversal tx has no writes and
|
||
// rolls back via defer, so the card is untouched.
|
||
if resumingRefund {
|
||
// Resumed a prior pending/failed row — Square money state is
|
||
// unknown, so leave the row for the sweep to reconcile.
|
||
slog.Error("CRITICAL: gift card %s changed state while a gift-card-cancel refund was pending (refund %s) — refund row left pending for sweep reconciliation", "gift_card_id", code, "refund_id", refundID)
|
||
} else {
|
||
// Freshly-inserted row in THIS request — Square was never called,
|
||
// so no money moved: mark it failed so it never blocks a retry.
|
||
// Roll back the reversal tx first so the failed-status write is
|
||
// not undone by the deferred rollback.
|
||
if rbErr := rtx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) {
|
||
log.Printf("Failed to rollback gift-card reversal transaction before marking refund failed: %v", rbErr)
|
||
}
|
||
log.Printf("Gift-card cancel aborted for %s: card state changed since eligibility — refund row %s marked failed, Square never called", code, refundID)
|
||
resolveRefundRow("failed", "")
|
||
}
|
||
http.Error(w, "This gift card has been redeemed, spent, or transferred since the cancellation was requested and can no longer be refunded", http.StatusConflict)
|
||
return
|
||
}
|
||
|
||
// Re-verified still eligible — issue the Square refund now. The refunds
|
||
// row is already committed (pending) with a deterministic key, so a
|
||
// crash/response-loss retry re-reads the row and resumes with the SAME
|
||
// key — Square dedups and the card is never double-refunded.
|
||
refundResult, sqErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
|
||
PaymentID: squarePaymentID,
|
||
Amount: int64(math.Round(refundAmount * 100)),
|
||
IdempotencyKey: refundKey,
|
||
Reason: giftCardCancelRefundReason,
|
||
})
|
||
|
||
switch {
|
||
case sqErr == nil:
|
||
status := "completed"
|
||
if refundResult.Status == "PENDING" {
|
||
// Square accepted the refund; the money is in flight. The card is
|
||
// still neutralised immediately — leaving the balance spendable
|
||
// while the refund is on its way would create value from nothing.
|
||
// The pending row stays for the sweep to reconcile.
|
||
status = "pending"
|
||
log.Printf("Square refund %s for gift-card purchase payment %s is PENDING — refund row left pending for reconciliation", refundResult.ID, paymentID)
|
||
} else if refundResult.Status == "FAILED" || refundResult.Status == "REJECTED" {
|
||
status = "failed"
|
||
log.Printf("Square refund %s for gift-card purchase payment %s FAILED — refund row marked failed", refundResult.ID, paymentID)
|
||
}
|
||
if status == "failed" {
|
||
// Roll back the reversal tx first so the failed-status write is
|
||
// not undone by the deferred rollback.
|
||
if rbErr := rtx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) {
|
||
log.Printf("Failed to rollback gift-card reversal transaction before marking refund failed: %v", rbErr)
|
||
}
|
||
resolveRefundRow("failed", refundResult.ID)
|
||
http.Error(w, "Refund failed", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Money accepted (COMPLETED) or in flight (PENDING) — reverse the
|
||
// gift-card funding and resolve the refunds row in THIS SAME
|
||
// transaction. On a COMPLETED refund the row flips to 'completed' only
|
||
// when this transaction commits, so a completed refund can never
|
||
// coexist with a live card (C2/F4). A PENDING Square refund stays
|
||
// 'pending' for the sweep. Any failure below rolls the whole
|
||
// transaction back: the card stays live and the row stays pending.
|
||
if cerr := cancelGiftCardFunding(ctx, rtx, code, userID, refundID, refundAmount); cerr != nil {
|
||
slog.Error("CRITICAL: Square refund accepted but gift card %s funding reversal failed — refund row left pending for sweep", "gift_card_id", code, "refund_id", refundID, "err", cerr)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if status == "completed" {
|
||
if _, upErr := rtx.Exec(ctx, `UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2`, refundResult.ID, refundID); upErr != nil {
|
||
slog.Error("CRITICAL: Square refund accepted but gift card %s refund row could not be resolved in the reversal transaction — row left pending", "gift_card_id", code, "refund_id", refundID, "err", upErr)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
} else if _, upErr := rtx.Exec(ctx, `UPDATE refunds SET square_refund_id = $1 WHERE id = $2`, refundResult.ID, refundID); upErr != nil {
|
||
slog.Error("CRITICAL: Square refund PENDING but gift card %s refund row could not record square_refund_id — row left pending", "gift_card_id", code, "refund_id", refundID, "err", upErr)
|
||
}
|
||
if cerr := rtx.Commit(ctx); cerr != nil {
|
||
slog.Error("CRITICAL: Square refund accepted but gift card %s reversal commit failed — refund row left pending, card left live", "gift_card_id", code, "refund_id", refundID, "err", cerr)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
message := "Gift card cancelled and the full amount refunded to your original payment method."
|
||
if partialRefund {
|
||
message = fmt.Sprintf("Gift card cancelled. £%.2f (the unspent portion) was refunded to your original payment method; the £%.2f already spent on salon services is not refundable.", refundAmount, spentAtTill)
|
||
}
|
||
w.WriteHeader(http.StatusOK)
|
||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||
"status": "success",
|
||
"message": message,
|
||
"refund_id": refundID,
|
||
"amount_refunded": refundAmount,
|
||
}); err != nil {
|
||
log.Printf("Failed to encode JSON response: %v", err)
|
||
}
|
||
return
|
||
|
||
case errors.Is(sqErr, square.ErrRefundAlreadyProcessed):
|
||
// PAYMENT_ALREADY_REFUNDED — the money already moved at Square (a
|
||
// prior same-key attempt of this flow refunded it). The card was
|
||
// re-verified above and still holds its full value: reverse the
|
||
// funding and resolve the row 'completed' in the SAME transaction
|
||
// before returning success.
|
||
if cerr := cancelGiftCardFunding(ctx, rtx, code, userID, refundID, refundAmount); cerr != nil {
|
||
slog.Error("CRITICAL: Square refund already processed but gift card %s funding reversal failed — manual reconciliation required", "gift_card_id", code, "err", cerr)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if _, upErr := rtx.Exec(ctx, `UPDATE refunds SET status = 'completed' WHERE id = $1`, refundID); upErr != nil {
|
||
slog.Error("CRITICAL: Square refund already processed but gift card %s refund row could not be resolved — manual reconciliation required", "gift_card_id", code, "err", upErr)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if cerr := rtx.Commit(ctx); cerr != nil {
|
||
slog.Error("CRITICAL: Square refund already processed but gift card %s reversal commit failed — manual reconciliation required", "gift_card_id", code, "err", cerr)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
log.Printf("Gift-card purchase %s already refunded at Square — refund %s marked completed, card %s cancelled", paymentID, refundID, code)
|
||
w.WriteHeader(http.StatusOK)
|
||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||
"status": "success",
|
||
"message": "This gift card had already been refunded and has now been cancelled.",
|
||
"refund_id": refundID,
|
||
"amount_refunded": refundAmount,
|
||
}); err != nil {
|
||
log.Printf("Failed to encode JSON response: %v", err)
|
||
}
|
||
return
|
||
|
||
case errors.Is(sqErr, square.ErrRefundDeclined):
|
||
// Definitive rejection — the money will never move. Mark the refund
|
||
// failed so it is never retried and never blocks the amount, and DO
|
||
// NOT cancel the card (the customer keeps it — the reversal tx has no
|
||
// writes and rolls back below).
|
||
if rbErr := rtx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) {
|
||
log.Printf("Failed to rollback gift-card reversal transaction before marking refund failed: %v", rbErr)
|
||
}
|
||
resolveRefundRow("failed", "")
|
||
log.Printf("Gift-card purchase refund %s definitively declined by Square: %v", refundID, sqErr)
|
||
http.Error(w, "Refund failed", http.StatusInternalServerError)
|
||
return
|
||
|
||
default:
|
||
// Ambiguous — Square may or may not have processed the refund. The
|
||
// reversal tx is rolled back (no writes) so the card stays live, the
|
||
// refunds row stays 'pending' for a same-key retry (Square dedups),
|
||
// and the gift card is NOT cancelled, per the failure-safety
|
||
// requirement.
|
||
log.Printf("Gift-card refund %s left pending after ambiguous Square error: %v", refundID, sqErr)
|
||
http.Error(w, "Refund failed — please try again", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
|
||
// giftCardSpendAtTill returns the total value of completed payments made
|
||
// against the gift card at the till. Till spend (handlers.go) decrements
|
||
// amount_remaining and records a payments row with payment_method='giftcard',
|
||
// status='completed' and gift_card_id = the card's code; transfers and
|
||
// top-ups deliberately do NOT create such rows. A shortfall between
|
||
// total_funds_added and amount_remaining that is not explained by this sum
|
||
// therefore signals an unverifiable alteration (a transfer, clawback, or
|
||
// partial refund), in which case the 14-day cancellation right cannot be
|
||
// exercised on the balance (reg 34(9) permits deducting only the value
|
||
// genuinely consumed on services and refunding the unspent remainder).
|
||
func giftCardSpendAtTill(ctx context.Context, q db.Querier, code string) (float64, error) {
|
||
var spentAtTill float64
|
||
err := q.QueryRow(ctx, `
|
||
SELECT COALESCE(SUM(amount), 0) FROM payments
|
||
WHERE gift_card_id = $1 AND status = 'completed'`, code).Scan(&spentAtTill)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return spentAtTill, nil
|
||
}
|
||
|
||
// findGiftCardPurchasePayment resolves the originating payments row for an
|
||
// online gift-card purchase (BuyGiftCard): booking_id IS NULL (the same
|
||
// discriminator RefundPayment and the stale-pending sweep use to identify
|
||
// gift-card purchases), created_by = the buyer, payment_method='online_square',
|
||
// status='completed' with a square_payment_id. When the client supplies the
|
||
// payment id it is verified against the same invariants instead of being
|
||
// matched by amount/timing. The payment row is created just before the Square
|
||
// charge and the gift card a moment later, so the auto-match uses a 15-minute
|
||
// window that ends at the purchase transaction's timestamp.
|
||
func findGiftCardPurchasePayment(ctx context.Context, q db.Querier, userID, suppliedPaymentID string, purchaseAmount float64, purchasedAt time.Time) (paymentID, squarePaymentID string, ok bool, err error) {
|
||
if suppliedPaymentID != "" {
|
||
var bookingID sql.NullString
|
||
var createdBy, status, method string
|
||
var sqID sql.NullString
|
||
var amount float64
|
||
err := q.QueryRow(ctx, `
|
||
SELECT id, booking_id, created_by, status, payment_method, square_payment_id, amount
|
||
FROM payments WHERE id = $1`, suppliedPaymentID).
|
||
Scan(&paymentID, &bookingID, &createdBy, &status, &method, &sqID, &amount)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return "", "", false, nil
|
||
}
|
||
if err != nil {
|
||
return "", "", false, err
|
||
}
|
||
if bookingID.Valid || createdBy != userID || status != "completed" || method != "online_square" {
|
||
return "", "", false, nil
|
||
}
|
||
if !sqID.Valid || !approxEqual(amount, purchaseAmount) {
|
||
return "", "", false, nil
|
||
}
|
||
return paymentID, sqID.String, true, nil
|
||
}
|
||
|
||
err = q.QueryRow(ctx, `
|
||
SELECT id, square_payment_id
|
||
FROM payments
|
||
WHERE booking_id IS NULL AND created_by = $1
|
||
AND payment_method = 'online_square' AND status = 'completed'
|
||
AND square_payment_id IS NOT NULL
|
||
AND ABS(amount - $2) < 0.005
|
||
AND created_at BETWEEN $3::timestamptz - INTERVAL '15 minutes' AND $3::timestamptz + INTERVAL '15 minutes'
|
||
ORDER BY created_at DESC
|
||
LIMIT 1`, userID, purchaseAmount, purchasedAt).
|
||
Scan(&paymentID, &squarePaymentID)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return "", "", false, nil
|
||
}
|
||
if err != nil {
|
||
return "", "", false, err
|
||
}
|
||
return paymentID, squarePaymentID, true, nil
|
||
}
|
||
|
||
// cancelGiftCardFunding reverses an online-purchased gift card after its
|
||
// refund has been accepted: the balance is zeroed and the card expired so it
|
||
// can never be redeemed or spent, and a 'cancelled' gift_card_transactions row
|
||
// records the reversal against the refund. Runs inside the caller's
|
||
// transaction.
|
||
func cancelGiftCardFunding(ctx context.Context, tx pgx.Tx, code, userID, refundID string, amount float64) error {
|
||
if _, err := tx.Exec(ctx, `
|
||
UPDATE gift_cards
|
||
SET amount_remaining = 0, expiry_date = NOW(), last_used_at = NOW()
|
||
WHERE id = $1`, code); err != nil {
|
||
return fmt.Errorf("failed to zero gift card %s: %w", code, err)
|
||
}
|
||
if _, err := tx.Exec(ctx, `
|
||
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
|
||
VALUES ($1, 'cancelled', $2, 'refund', $3, $4, '14-day cooling-off cancellation — refunded to original payment method')
|
||
`, code, amount, refundID, userID); err != nil {
|
||
return fmt.Errorf("failed to record gift card cancellation transaction for %s: %w", code, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// approxEqual reports whether two currency amounts are equal to the nearest
|
||
// penny (float64 scans of NUMERIC can carry tiny representation error).
|
||
func approxEqual(a, b float64) bool {
|
||
return math.Abs(a-b) < 0.005
|
||
}
|