Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1131 lines
33 KiB
Go
1131 lines
33 KiB
Go
package payments
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"crussell/db"
|
|
"crussell/internal/square"
|
|
"crussell/internal/validators"
|
|
"crussell/mw"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// --- Types ---
|
|
|
|
type GiftCard struct {
|
|
ID string `json:"id"`
|
|
TotalFundsAdded float64 `json:"total_funds_added"`
|
|
AmountRemaining float64 `json:"amount_remaining"`
|
|
CreatedBy *string `json:"created_by,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
RedeemedAt *time.Time `json:"redeemed_at,omitempty"`
|
|
RedeemedBy *string `json:"redeemed_by,omitempty"`
|
|
IsInventory bool `json:"is_inventory"`
|
|
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
|
}
|
|
|
|
type UserBalance struct {
|
|
UserID string `json:"user_id"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
Balance float64 `json:"balance"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type GiftCardListResponse struct {
|
|
TotalUnclaimed float64 `json:"total_unclaimed"`
|
|
TotalUserBalances float64 `json:"total_user_balances"`
|
|
GiftCards []GiftCard `json:"gift_cards"`
|
|
UserBalances []UserBalance `json:"user_balances"`
|
|
Total int `json:"total"`
|
|
UBTotal int `json:"ub_total"`
|
|
Page int `json:"page"`
|
|
PerPage int `json:"perPage"`
|
|
TotalPages int `json:"totalPages"`
|
|
NextCursor *string `json:"next_cursor,omitempty"`
|
|
}
|
|
|
|
type CreateGiftCardRequest struct {
|
|
Amount float64 `json:"amount"`
|
|
IsInventory bool `json:"is_inventory,omitempty"`
|
|
}
|
|
|
|
type TopUpGiftCardRequest struct {
|
|
Amount float64 `json:"amount"`
|
|
PaymentMethod string `json:"payment_method"`
|
|
}
|
|
|
|
type TransferGiftCardRequest struct {
|
|
ToCardID string `json:"to_card_id"`
|
|
Amount float64 `json:"amount"`
|
|
}
|
|
|
|
type BuyGiftCardRequest struct {
|
|
Amount int64 `json:"amount"`
|
|
RecipientType string `json:"recipient_type"`
|
|
RecipientEmail string `json:"recipient_email,omitempty"`
|
|
CardID *string `json:"card_id,omitempty"`
|
|
NewCardToken *string `json:"new_card_token,omitempty"`
|
|
SaveCard bool `json:"save_card"`
|
|
IdempotencyKey string `json:"idempotency_key"`
|
|
}
|
|
|
|
type RedeemGiftCardRequest struct {
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
// --- Admin Handlers ---
|
|
|
|
func GetGiftCards(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
// Parse query parameters
|
|
query := r.URL.Query()
|
|
searchTerm := query.Get("q")
|
|
|
|
// Pagination parameters
|
|
perPage := 10
|
|
cursorStr := query.Get("cursor")
|
|
|
|
if perPageStr := query.Get("per_page"); perPageStr != "" {
|
|
if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 {
|
|
perPage = pp
|
|
}
|
|
}
|
|
|
|
// Accept page param for backward compat (deprecated)
|
|
page := 1
|
|
if pageStr := query.Get("page"); pageStr != "" {
|
|
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
|
|
page = p
|
|
}
|
|
}
|
|
|
|
var resp GiftCardListResponse
|
|
resp.GiftCards = []GiftCard{}
|
|
resp.UserBalances = []UserBalance{}
|
|
resp.Page = page
|
|
resp.PerPage = perPage
|
|
|
|
// --- Aggregate totals (unfiltered, unpaginated) ---
|
|
|
|
err := db.DB.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.DB.QueryRow(ctx, `
|
|
SELECT COALESCE(SUM(balance), 0)
|
|
FROM user_giftcard_balances
|
|
`).Scan(&resp.TotalUserBalances)
|
|
if err != nil {
|
|
log.Printf("Failed to calculate total user balances: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// --- Gift cards (paginated, optionally filtered) ---
|
|
|
|
filterType := query.Get("type")
|
|
whereClauses := []string{}
|
|
if searchTerm != "" {
|
|
whereClauses = append(whereClauses, "id ILIKE $1")
|
|
}
|
|
if filterType == "customer" {
|
|
whereClauses = append(whereClauses, "is_inventory = FALSE")
|
|
} else if filterType == "inventory" {
|
|
whereClauses = append(whereClauses, "is_inventory = TRUE")
|
|
}
|
|
|
|
whereSQL := ""
|
|
if len(whereClauses) > 0 {
|
|
whereSQL = "WHERE " + strings.Join(whereClauses, " AND ")
|
|
}
|
|
|
|
var gcTotal int
|
|
var gcListArgs []interface{}
|
|
|
|
gcListQuery := fmt.Sprintf(`
|
|
SELECT id, total_funds_added, amount_remaining, created_at, is_inventory
|
|
FROM gift_cards
|
|
%s
|
|
`, whereSQL)
|
|
|
|
if searchTerm != "" {
|
|
searchPattern := "%" + searchTerm + "%"
|
|
gcListArgs = []interface{}{searchPattern}
|
|
|
|
if cursorStr != "" {
|
|
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
|
|
if err != nil {
|
|
http.Error(w, "invalid cursor: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
gcListQuery += " AND (created_at, id) < ($2, $3)"
|
|
gcListArgs = append(gcListArgs, cursorCreatedAt, cursorID)
|
|
}
|
|
gcListQuery += " ORDER BY created_at DESC, id DESC LIMIT $" + strconv.Itoa(len(gcListArgs)+1)
|
|
gcListArgs = append(gcListArgs, perPage+1)
|
|
} else {
|
|
if cursorStr != "" {
|
|
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
|
|
if err != nil {
|
|
http.Error(w, "invalid cursor: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
gcListQuery += " WHERE (created_at, id) < ($1, $2)"
|
|
gcListArgs = append(gcListArgs, cursorCreatedAt, cursorID)
|
|
}
|
|
gcListQuery += " ORDER BY created_at DESC, id DESC LIMIT $" + strconv.Itoa(len(gcListArgs)+1)
|
|
gcListArgs = append(gcListArgs, perPage+1)
|
|
}
|
|
|
|
gcRows, err := db.DB.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()
|
|
|
|
// Count query: total matching gift cards (same WHERE, without cursor/ORDER BY/LIMIT).
|
|
gcTotal = 0
|
|
if whereSQL != "" {
|
|
countArgs := []interface{}{}
|
|
if searchTerm != "" {
|
|
countArgs = append(countArgs, "%"+searchTerm+"%")
|
|
}
|
|
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards "+whereSQL, countArgs...).Scan(&gcTotal)
|
|
} else {
|
|
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards").Scan(&gcTotal)
|
|
}
|
|
|
|
for gcRows.Next() {
|
|
var gc GiftCard
|
|
|
|
err = gcRows.Scan(
|
|
&gc.ID,
|
|
&gc.TotalFundsAdded,
|
|
&gc.AmountRemaining,
|
|
&gc.CreatedAt,
|
|
&gc.IsInventory,
|
|
)
|
|
if err != nil {
|
|
log.Printf("Failed to scan gift card: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resp.GiftCards = append(resp.GiftCards, gc)
|
|
}
|
|
|
|
// --- User balances (all, optionally filtered) ---
|
|
|
|
var ubTotal int
|
|
var ubListQuery string
|
|
var ubListArgs []interface{}
|
|
|
|
if searchTerm != "" {
|
|
searchPattern := "%" + searchTerm + "%"
|
|
|
|
ubListQuery = `
|
|
SELECT
|
|
b.user_id, u.n_first_name || ' ' || u.n_last_name AS name, u.email, b.balance, b.updated_at
|
|
FROM user_giftcard_balances b
|
|
JOIN users u ON b.user_id = u.id
|
|
WHERE u.n_first_name ILIKE $1
|
|
OR u.n_last_name ILIKE $1
|
|
OR u.email ILIKE $1
|
|
ORDER BY b.updated_at DESC
|
|
`
|
|
ubListArgs = []interface{}{searchPattern}
|
|
} else {
|
|
ubListQuery = `
|
|
SELECT
|
|
b.user_id, u.n_first_name || ' ' || u.n_last_name AS name, u.email, b.balance, b.updated_at
|
|
FROM user_giftcard_balances b
|
|
JOIN users u ON b.user_id = u.id
|
|
ORDER BY b.updated_at DESC
|
|
`
|
|
ubListArgs = []interface{}{}
|
|
}
|
|
|
|
ubRows, err := db.DB.Query(ctx, ubListQuery, ubListArgs...)
|
|
if err != nil {
|
|
log.Printf("Failed to query user balances: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer ubRows.Close()
|
|
|
|
// Count query for user balances.
|
|
if searchTerm != "" {
|
|
db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM user_giftcard_balances b
|
|
JOIN users u ON b.user_id = u.id
|
|
WHERE u.n_first_name ILIKE $1 OR u.n_last_name ILIKE $1 OR u.email ILIKE $1`, "%"+searchTerm+"%").Scan(&ubTotal)
|
|
} else {
|
|
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM user_giftcard_balances").Scan(&ubTotal)
|
|
}
|
|
|
|
for ubRows.Next() {
|
|
var ub UserBalance
|
|
err = ubRows.Scan(
|
|
&ub.UserID,
|
|
&ub.Name,
|
|
&ub.Email,
|
|
&ub.Balance,
|
|
&ub.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
log.Printf("Failed to scan user balance: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
resp.UserBalances = append(resp.UserBalances, ub)
|
|
}
|
|
|
|
resp.UBTotal = ubTotal
|
|
resp.Total = gcTotal
|
|
|
|
var nextCursor *string
|
|
if len(resp.GiftCards) > perPage {
|
|
resp.GiftCards = resp.GiftCards[:perPage]
|
|
last := resp.GiftCards[len(resp.GiftCards)-1]
|
|
cursor := last.CreatedAt.Format(time.RFC3339) + "|" + last.ID
|
|
nextCursor = &cursor
|
|
}
|
|
resp.NextCursor = nextCursor
|
|
|
|
totalPages := (gcTotal + perPage - 1) / perPage
|
|
if totalPages == 0 {
|
|
totalPages = 1
|
|
}
|
|
resp.TotalPages = totalPages
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
func CreateGiftCard(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
adminID, _ := ctx.Value(mw.UserIDKey).(string)
|
|
|
|
var req CreateGiftCardRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Amount < 0 {
|
|
http.Error(w, "Amount must not be negative", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Amount == 0 && !req.IsInventory {
|
|
http.Error(w, "Amount must be greater than zero", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
tx, err := db.DB.Begin(ctx)
|
|
if err != nil {
|
|
log.Printf("Failed to begin transaction: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var gc GiftCard
|
|
var lastUsedAt sql.NullTime
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at)
|
|
VALUES ($1, $1, $2, $3, NOW())
|
|
RETURNING id, total_funds_added, amount_remaining, created_by, created_at, is_inventory, last_used_at
|
|
`, req.Amount, adminID, req.IsInventory).Scan(
|
|
&gc.ID,
|
|
&gc.TotalFundsAdded,
|
|
&gc.AmountRemaining,
|
|
&gc.CreatedBy,
|
|
&gc.CreatedAt,
|
|
&gc.IsInventory,
|
|
&lastUsedAt,
|
|
)
|
|
if err != nil {
|
|
log.Printf("Failed to create gift card: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if lastUsedAt.Valid {
|
|
gc.LastUsedAt = &lastUsedAt.Time
|
|
}
|
|
|
|
var notes *string
|
|
if req.IsInventory {
|
|
s := "inventory card"
|
|
notes = &s
|
|
} else {
|
|
s := "giveaway"
|
|
notes = &s
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
|
|
VALUES ($1, 'purchase', $2, 'api', NULL, $3, $4)
|
|
`, gc.ID, req.Amount, adminID, notes)
|
|
if err != nil {
|
|
log.Printf("Failed to record gift card transaction: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
log.Printf("Failed to commit transaction: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(gc)
|
|
}
|
|
|
|
func TopUpGiftCard(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
adminID, _ := ctx.Value(mw.UserIDKey).(string)
|
|
cardID := chi.URLParam(r, "id")
|
|
if cardID == "" || !validators.IsValidID(cardID) {
|
|
http.Error(w, "Invalid gift card ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var req TopUpGiftCardRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Amount <= 0 {
|
|
http.Error(w, "Amount must be greater than zero", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
validMethods := map[string]string{
|
|
"cash": "cash",
|
|
"card_machine": "in_person_card",
|
|
"online_square": "online_square",
|
|
"on_the_house": "on_the_house",
|
|
}
|
|
_, ok := validMethods[req.PaymentMethod]
|
|
if !ok {
|
|
http.Error(w, "Invalid payment method. Must be one of: cash, card_machine, online_square, on_the_house", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
tx, err := db.DB.Begin(ctx)
|
|
if err != nil {
|
|
log.Printf("Failed to begin transaction: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var redeemedBy sql.NullString
|
|
var isInventory bool
|
|
var currentTotalFunds float64
|
|
err = tx.QueryRow(ctx, "SELECT redeemed_by, is_inventory, total_funds_added FROM gift_cards WHERE id = $1", cardID).Scan(&redeemedBy, &isInventory, ¤tTotalFunds)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.Error(w, "Gift card not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to check gift card: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if redeemedBy.Valid {
|
|
http.Error(w, "Cannot top up a card that has already been redeemed to an account", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
txType := "topup"
|
|
var notes *string
|
|
if isInventory && currentTotalFunds == 0 {
|
|
txType = "purchase"
|
|
s := "first top-up on inventory card"
|
|
notes = &s
|
|
}
|
|
|
|
var gc GiftCard
|
|
var createdBy sql.NullString
|
|
err = tx.QueryRow(ctx, `
|
|
UPDATE gift_cards
|
|
SET total_funds_added = total_funds_added + $1,
|
|
amount_remaining = amount_remaining + $1,
|
|
last_used_at = NOW()
|
|
WHERE id = $2
|
|
RETURNING id, total_funds_added, amount_remaining, created_by, created_at
|
|
`, req.Amount, cardID).Scan(
|
|
&gc.ID,
|
|
&gc.TotalFundsAdded,
|
|
&gc.AmountRemaining,
|
|
&createdBy,
|
|
&gc.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
log.Printf("Failed to top up gift card: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if createdBy.Valid {
|
|
gc.CreatedBy = &createdBy.String
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
|
|
VALUES ($1, $2, $3, 'api', NULL, $4, $5)
|
|
`, cardID, txType, req.Amount, adminID, notes)
|
|
if err != nil {
|
|
log.Printf("Failed to record gift card transaction: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
log.Printf("Failed to commit transaction: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(gc)
|
|
}
|
|
|
|
func TransferGiftCard(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
fromCardID := chi.URLParam(r, "from")
|
|
if fromCardID == "" || !validators.IsValidID(fromCardID) {
|
|
http.Error(w, "Invalid source gift card ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var req TransferGiftCardRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
req.ToCardID = validators.NormalizeGiftCardCode(req.ToCardID)
|
|
if !validators.IsValidID(req.ToCardID) {
|
|
http.Error(w, "Invalid destination gift card ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if fromCardID == req.ToCardID {
|
|
http.Error(w, "Source and destination cards must be different", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Amount <= 0 {
|
|
http.Error(w, "Amount must be greater than zero", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
tx, err := db.DB.Begin(ctx)
|
|
if err != nil {
|
|
log.Printf("Failed to begin transaction: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var fromRedeemedBy, toRedeemedBy sql.NullString
|
|
var fromRemaining, toRemaining float64
|
|
|
|
err = tx.QueryRow(ctx, "SELECT redeemed_by, amount_remaining FROM gift_cards WHERE id = $1", fromCardID).Scan(&fromRedeemedBy, &fromRemaining)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.Error(w, "Source gift card not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to check source gift card: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
err = tx.QueryRow(ctx, "SELECT redeemed_by, amount_remaining FROM gift_cards WHERE id = $1", req.ToCardID).Scan(&toRedeemedBy, &toRemaining)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.Error(w, "Destination gift card not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to check destination gift card: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if fromRedeemedBy.Valid || toRedeemedBy.Valid {
|
|
http.Error(w, "Cannot transfer balance to/from cards redeemed to accounts", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if fromRemaining < req.Amount {
|
|
http.Error(w, "Insufficient balance on source gift card", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE gift_cards
|
|
SET amount_remaining = amount_remaining - $1,
|
|
last_used_at = NOW()
|
|
WHERE id = $2
|
|
`, req.Amount, fromCardID)
|
|
if err != nil {
|
|
log.Printf("Failed to deduct from source: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE gift_cards
|
|
SET amount_remaining = amount_remaining + $1,
|
|
total_funds_added = total_funds_added + $1,
|
|
last_used_at = NOW()
|
|
WHERE id = $2
|
|
`, req.Amount, req.ToCardID)
|
|
if err != nil {
|
|
log.Printf("Failed to add to destination: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
log.Printf("Failed to commit transaction: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
|
}
|
|
|
|
// --- User Handlers ---
|
|
|
|
func RedeemGiftCard(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
userID, ok := ctx.Value(mw.UserIDKey).(string)
|
|
if !ok || userID == "" {
|
|
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req RedeemGiftCardRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
code := validators.NormalizeGiftCardCode(req.Code)
|
|
if !validators.IsValidID(code) {
|
|
http.Error(w, "Invalid gift card code format", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
tx, err := db.DB.Begin(ctx)
|
|
if err != nil {
|
|
log.Printf("Failed to begin transaction: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var amountRemaining float64
|
|
var redeemedBy sql.NullString
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT amount_remaining, redeemed_by
|
|
FROM gift_cards
|
|
WHERE id = $1
|
|
FOR UPDATE
|
|
`, code).Scan(&amountRemaining, &redeemedBy)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.Error(w, "Invalid or expired gift card code", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to query gift card: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if redeemedBy.Valid {
|
|
http.Error(w, "This gift card has already been redeemed", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if amountRemaining <= 0 {
|
|
http.Error(w, "This gift card has no remaining balance", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE gift_cards
|
|
SET amount_remaining = 0,
|
|
redeemed_at = NOW(),
|
|
redeemed_by = $1
|
|
WHERE id = $2
|
|
`, userID, code)
|
|
if err != nil {
|
|
log.Printf("Failed to update gift card: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
|
|
VALUES ($1, $2, NOW())
|
|
ON CONFLICT (user_id) DO UPDATE SET
|
|
balance = user_giftcard_balances.balance + EXCLUDED.balance,
|
|
updated_at = NOW()
|
|
`, userID, amountRemaining)
|
|
if err != nil {
|
|
log.Printf("Failed to update user gift card balance: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
|
|
VALUES ($1, 'redeem_to_balance', $2, 'api', NULL, $3, NULL)
|
|
`, code, amountRemaining, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to record gift card transaction: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
log.Printf("Failed to commit transaction: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"status": "success",
|
|
"amount_redeemed": amountRemaining,
|
|
})
|
|
}
|
|
|
|
func GetGiftCardBalance(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
userID, ok := ctx.Value(mw.UserIDKey).(string)
|
|
if !ok || userID == "" {
|
|
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var balance float64
|
|
err := db.DB.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]float64{"balance": 0.00})
|
|
return
|
|
}
|
|
log.Printf("Failed to query user balance: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]float64{"balance": balance})
|
|
}
|
|
|
|
// GetUserGiftCardBalanceAdmin Handler returns any user's balance for the admin.
|
|
func GetUserGiftCardBalanceAdmin(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
userID := chi.URLParam(r, "id")
|
|
if userID == "" || !validators.IsValidID(userID) {
|
|
http.Error(w, "Invalid user ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
adminID, _ := ctx.Value(mw.UserIDKey).(string)
|
|
|
|
var balance float64
|
|
err := db.DB.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]float64{"balance": 0.00})
|
|
return
|
|
}
|
|
log.Printf("Failed to query user balance: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
detailsJSON := fmt.Sprintf(`{"balance": %.2f}`, balance)
|
|
if _, err := db.DB.Exec(ctx, `
|
|
INSERT INTO admin_audit_log (admin_id, action_type, target_user_id, details)
|
|
VALUES ($1, 'balance_check', $2, $3::jsonb)
|
|
`, adminID, userID, detailsJSON); err != nil {
|
|
log.Printf("Failed to record admin_audit_log (non-critical): %v", err)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]float64{"balance": balance})
|
|
}
|
|
|
|
func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
userID, ok := ctx.Value(mw.UserIDKey).(string)
|
|
if !ok || userID == "" {
|
|
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req BuyGiftCardRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
allowedAmounts := map[int64]bool{1000: true, 2000: true, 5000: true}
|
|
if !allowedAmounts[req.Amount] {
|
|
http.Error(w, "Invalid amount. Must be £10, £20, or £50.", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.RecipientType != "self" && req.RecipientType != "friend" {
|
|
http.Error(w, "Invalid recipient type", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := ValidateCardInfo(req.CardID, req.NewCardToken); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
paymentService := NewPaymentService()
|
|
|
|
if req.IdempotencyKey != "" {
|
|
existing, err := paymentService.CheckIdempotencyByKey(ctx, req.IdempotencyKey)
|
|
if err != nil {
|
|
log.Printf("Failed to check idempotency: %v", err)
|
|
}
|
|
if existing != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(existing)
|
|
return
|
|
}
|
|
}
|
|
|
|
var sourceID string
|
|
var savedCardID *string
|
|
|
|
if req.NewCardToken != nil && *req.NewCardToken != "" {
|
|
cardOnFile, err := SquareClient.CreateCardOnFile(ctx, userID, *req.NewCardToken)
|
|
if err != nil {
|
|
log.Printf("Failed to create card on file: %v", err)
|
|
http.Error(w, "Failed to process card", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
sourceID = cardOnFile.CardID
|
|
|
|
if req.SaveCard {
|
|
cardID, err := paymentService.SaveCardForUser(ctx, userID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint)
|
|
if err != nil {
|
|
log.Printf("Failed to save card: %v", err)
|
|
} else {
|
|
savedCardID = &cardID
|
|
}
|
|
}
|
|
} else if req.CardID != nil {
|
|
card, err := paymentService.GetCardByID(ctx, *req.CardID, userID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.Error(w, "Card not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to get card: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
sourceID = card.SquareCardID
|
|
savedCardID = req.CardID
|
|
}
|
|
|
|
paymentReq := square.CreatePaymentReq{
|
|
Amount: req.Amount,
|
|
Currency: "GBP",
|
|
SourceID: sourceID,
|
|
IdempotencyKey: req.IdempotencyKey,
|
|
Note: "Gift Card Purchase",
|
|
}
|
|
|
|
paymentResult, err := SquareClient.CreatePayment(ctx, paymentReq)
|
|
if err != nil {
|
|
log.Printf("Failed to process gift card purchase payment: %v", err)
|
|
http.Error(w, "Payment failed", http.StatusPaymentRequired)
|
|
return
|
|
}
|
|
|
|
tx, err := db.DB.Begin(ctx)
|
|
if err != nil {
|
|
log.Printf("Failed to begin transaction: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
amountPounds := float64(req.Amount) / 100.0
|
|
|
|
var cardID string
|
|
|
|
if req.RecipientType == "self" {
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by, is_inventory)
|
|
VALUES ($1, 0, $2, NOW(), $2, FALSE)
|
|
RETURNING id
|
|
`, amountPounds, userID).Scan(&cardID)
|
|
if err != nil {
|
|
log.Printf("Failed to insert gift card: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
|
|
VALUES ($1, $2, NOW())
|
|
ON CONFLICT (user_id) DO UPDATE SET
|
|
balance = user_giftcard_balances.balance + EXCLUDED.balance,
|
|
updated_at = NOW()
|
|
`, userID, amountPounds)
|
|
if err != nil {
|
|
log.Printf("Failed to update balance: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
|
|
VALUES ($1, 'purchase', $2, 'api', NULL, $3, 'self-purchase, auto-redeemed')
|
|
`, cardID, amountPounds, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to record gift card transaction: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
} else {
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory)
|
|
VALUES ($1, $1, $2, FALSE)
|
|
RETURNING id
|
|
`, amountPounds, userID).Scan(&cardID)
|
|
if err != nil {
|
|
log.Printf("Failed to insert gift card: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
recipient := req.RecipientEmail
|
|
if recipient == "" {
|
|
var userEmail string
|
|
_ = tx.QueryRow(ctx, "SELECT email FROM users WHERE id = $1", userID).Scan(&userEmail)
|
|
recipient = userEmail
|
|
}
|
|
log.Printf("[TODO EMAIL] Send gift card code %s (Value: £%.2f) to %s", cardID, amountPounds, recipient)
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
|
|
VALUES ($1, 'purchase', $2, 'api', NULL, $3, 'purchased for friend')
|
|
`, cardID, amountPounds, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to record gift card transaction: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
fees := paymentService.CalculateFees(req.Amount, "online")
|
|
record := PaymentRecord{
|
|
PaymentType: "full",
|
|
PaymentMethod: "online_square",
|
|
Status: "completed",
|
|
Amount: amountPounds,
|
|
SquarePaymentID: &paymentResult.SquarePayID,
|
|
IdempotencyKey: &req.IdempotencyKey,
|
|
Fees: float64(fees) / 100.0,
|
|
UserSavedCardID: savedCardID,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
CreatedBy: &userID,
|
|
}
|
|
|
|
_, err = tx.Exec(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)
|
|
`, record.PaymentType, record.PaymentMethod, record.Status, record.Amount, record.SquarePaymentID, record.IdempotencyKey, record.Fees, record.UserSavedCardID, record.CreatedBy, record.CreatedAt, record.UpdatedAt)
|
|
if err != nil {
|
|
log.Printf("Failed to insert payment record: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
log.Printf("Failed to commit buy transaction: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"status": "success",
|
|
"code": cardID,
|
|
"amount": amountPounds,
|
|
})
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
type ExpiredBalance struct {
|
|
ID string `json:"id"`
|
|
AccountID *string `json:"account_id,omitempty"`
|
|
OriginalBalance float64 `json:"original_balance"`
|
|
ExpiredAt time.Time `json:"expired_at"`
|
|
ClaimedAt *time.Time `json:"claimed_at,omitempty"`
|
|
ClaimedByAdmin *string `json:"claimed_by_admin,omitempty"`
|
|
Notes *string `json:"notes,omitempty"`
|
|
}
|
|
|
|
func GetExpiredBalances(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
rows, err := db.DB.Query(ctx, `
|
|
SELECT id, account_id, original_balance, expired_at, claimed_at, claimed_by_admin, notes
|
|
FROM gift_card_expired_balances
|
|
ORDER BY expired_at DESC
|
|
`)
|
|
if err != nil {
|
|
log.Printf("Failed to query expired balances: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var balances []ExpiredBalance
|
|
for rows.Next() {
|
|
var b ExpiredBalance
|
|
var accountID, claimedByAdmin, notes sql.NullString
|
|
var claimedAt sql.NullTime
|
|
|
|
err = rows.Scan(&b.ID, &accountID, &b.OriginalBalance, &b.ExpiredAt, &claimedAt, &claimedByAdmin, ¬es)
|
|
if err != nil {
|
|
log.Printf("Failed to scan expired balance: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if accountID.Valid {
|
|
b.AccountID = &accountID.String
|
|
}
|
|
if claimedAt.Valid {
|
|
b.ClaimedAt = &claimedAt.Time
|
|
}
|
|
if claimedByAdmin.Valid {
|
|
b.ClaimedByAdmin = &claimedByAdmin.String
|
|
}
|
|
if notes.Valid {
|
|
b.Notes = ¬es.String
|
|
}
|
|
|
|
balances = append(balances, b)
|
|
}
|
|
|
|
if balances == nil {
|
|
balances = []ExpiredBalance{}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"expired_balances": balances,
|
|
"total": len(balances),
|
|
})
|
|
}
|
|
|
|
type ClaimExpiredBalanceRequest struct {
|
|
BalanceID string `json:"balance_id"`
|
|
Notes *string `json:"notes,omitempty"`
|
|
}
|
|
|
|
func ClaimExpiredBalance(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
adminID, ok := ctx.Value(mw.UserIDKey).(string)
|
|
if !ok || adminID == "" {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req ClaimExpiredBalanceRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.BalanceID == "" {
|
|
http.Error(w, "balance_id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var existingClaimedAt sql.NullTime
|
|
err := db.DB.QueryRow(ctx, `
|
|
SELECT claimed_at FROM gift_card_expired_balances WHERE id = $1
|
|
`, req.BalanceID).Scan(&existingClaimedAt)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.Error(w, "Expired balance not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to check expired balance: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if existingClaimedAt.Valid {
|
|
http.Error(w, "Balance already claimed", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
_, err = db.DB.Exec(ctx, `
|
|
UPDATE gift_card_expired_balances
|
|
SET claimed_at = NOW(), claimed_by_admin = $1, notes = $2
|
|
WHERE id = $3
|
|
`, adminID, req.Notes, req.BalanceID)
|
|
if err != nil {
|
|
log.Printf("Failed to claim expired balance: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "claimed"})
|
|
}
|