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