feat(payments): add gift card system v2 with management, expiry, inventory, audit log
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
@@ -27,6 +28,8 @@ type GiftCard struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
RedeemedAt *time.Time `json:"redeemed_at,omitempty"`
|
RedeemedAt *time.Time `json:"redeemed_at,omitempty"`
|
||||||
RedeemedBy *string `json:"redeemed_by,omitempty"`
|
RedeemedBy *string `json:"redeemed_by,omitempty"`
|
||||||
|
IsInventory bool `json:"is_inventory"`
|
||||||
|
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserBalance struct {
|
type UserBalance struct {
|
||||||
@@ -37,19 +40,26 @@ type UserBalance struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GiftCardSummary struct {
|
type GiftCardListResponse struct {
|
||||||
TotalUnclaimed float64 `json:"total_unclaimed"`
|
TotalUnclaimed float64 `json:"total_unclaimed"`
|
||||||
TotalUserBalances float64 `json:"total_user_balances"`
|
TotalUserBalances float64 `json:"total_user_balances"`
|
||||||
GiftCards []GiftCard `json:"gift_cards"`
|
GiftCards []GiftCard `json:"gift_cards"`
|
||||||
UserBalances []UserBalance `json:"user_balances"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateGiftCardRequest struct {
|
type CreateGiftCardRequest struct {
|
||||||
Amount float64 `json:"amount"` // in pounds (e.g. 10.00, 25.00)
|
Amount float64 `json:"amount"`
|
||||||
|
IsInventory bool `json:"is_inventory,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TopUpGiftCardRequest struct {
|
type TopUpGiftCardRequest struct {
|
||||||
Amount float64 `json:"amount"` // in pounds
|
Amount float64 `json:"amount"`
|
||||||
|
PaymentMethod string `json:"payment_method"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TransferGiftCardRequest struct {
|
type TransferGiftCardRequest struct {
|
||||||
@@ -76,14 +86,41 @@ type RedeemGiftCardRequest struct {
|
|||||||
func GetGiftCards(w http.ResponseWriter, r *http.Request) {
|
func GetGiftCards(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
var summary GiftCardSummary
|
// Parse query parameters
|
||||||
summary.GiftCards = []GiftCard{}
|
query := r.URL.Query()
|
||||||
|
searchTerm := query.Get("q")
|
||||||
|
|
||||||
|
// Pagination parameters
|
||||||
|
page := 1
|
||||||
|
perPage := 10
|
||||||
|
|
||||||
|
if pageStr := query.Get("page"); pageStr != "" {
|
||||||
|
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
|
||||||
|
page = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if perPageStr := query.Get("per_page"); perPageStr != "" {
|
||||||
|
if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 {
|
||||||
|
perPage = pp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (page - 1) * perPage
|
||||||
|
|
||||||
|
var resp GiftCardListResponse
|
||||||
|
resp.GiftCards = []GiftCard{}
|
||||||
|
resp.UserBalances = []UserBalance{}
|
||||||
|
resp.Page = page
|
||||||
|
resp.PerPage = perPage
|
||||||
|
|
||||||
|
// --- Aggregate totals (unfiltered, unpaginated) ---
|
||||||
|
|
||||||
err := db.DB.QueryRow(ctx, `
|
err := db.DB.QueryRow(ctx, `
|
||||||
SELECT COALESCE(SUM(amount_remaining), 0)
|
SELECT COALESCE(SUM(amount_remaining), 0)
|
||||||
FROM gift_cards
|
FROM gift_cards
|
||||||
WHERE redeemed_by IS NULL
|
WHERE redeemed_by IS NULL
|
||||||
`).Scan(&summary.TotalUnclaimed)
|
`).Scan(&resp.TotalUnclaimed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to calculate total unclaimed gift cards: %v", err)
|
log.Printf("Failed to calculate total unclaimed gift cards: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
@@ -93,31 +130,73 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
|
|||||||
err = db.DB.QueryRow(ctx, `
|
err = db.DB.QueryRow(ctx, `
|
||||||
SELECT COALESCE(SUM(balance), 0)
|
SELECT COALESCE(SUM(balance), 0)
|
||||||
FROM user_giftcard_balances
|
FROM user_giftcard_balances
|
||||||
`).Scan(&summary.TotalUserBalances)
|
`).Scan(&resp.TotalUserBalances)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to calculate total user balances: %v", err)
|
log.Printf("Failed to calculate total user balances: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := db.DB.Query(ctx, `
|
// --- Gift cards (paginated, optionally filtered) ---
|
||||||
SELECT id, total_funds_added, amount_remaining, created_by, created_at, redeemed_at, redeemed_by
|
|
||||||
FROM gift_cards
|
var gcTotal int
|
||||||
ORDER BY created_at DESC
|
var gcCountQuery string
|
||||||
`)
|
var gcListQuery string
|
||||||
|
var gcCountArgs []interface{}
|
||||||
|
var gcListArgs []interface{}
|
||||||
|
|
||||||
|
if searchTerm != "" {
|
||||||
|
searchPattern := "%" + searchTerm + "%"
|
||||||
|
|
||||||
|
gcCountQuery = `
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM gift_cards
|
||||||
|
WHERE id ILIKE $1
|
||||||
|
`
|
||||||
|
gcCountArgs = []interface{}{searchPattern}
|
||||||
|
|
||||||
|
gcListQuery = `
|
||||||
|
SELECT id, total_funds_added, amount_remaining, created_by, created_at, redeemed_at, redeemed_by, is_inventory, last_used_at
|
||||||
|
FROM gift_cards
|
||||||
|
WHERE id ILIKE $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
`
|
||||||
|
gcListArgs = []interface{}{searchPattern, perPage, offset}
|
||||||
|
} else {
|
||||||
|
gcCountQuery = `SELECT COUNT(*) FROM gift_cards`
|
||||||
|
gcCountArgs = []interface{}{}
|
||||||
|
|
||||||
|
gcListQuery = `
|
||||||
|
SELECT id, total_funds_added, amount_remaining, created_by, created_at, redeemed_at, redeemed_by, is_inventory, last_used_at
|
||||||
|
FROM gift_cards
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
`
|
||||||
|
gcListArgs = []interface{}{perPage, offset}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.DB.QueryRow(ctx, gcCountQuery, gcCountArgs...).Scan(&gcTotal)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to count gift cards: %v", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gcRows, err := db.DB.Query(ctx, gcListQuery, gcListArgs...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to query gift cards: %v", err)
|
log.Printf("Failed to query gift cards: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer gcRows.Close()
|
||||||
|
|
||||||
for rows.Next() {
|
for gcRows.Next() {
|
||||||
var gc GiftCard
|
var gc GiftCard
|
||||||
var createdBy, redeemedBy sql.NullString
|
var createdBy, redeemedBy sql.NullString
|
||||||
var redeemedAt sql.NullTime
|
var redeemedAt, lastUsedAt sql.NullTime
|
||||||
|
|
||||||
err = rows.Scan(
|
err = gcRows.Scan(
|
||||||
&gc.ID,
|
&gc.ID,
|
||||||
&gc.TotalFundsAdded,
|
&gc.TotalFundsAdded,
|
||||||
&gc.AmountRemaining,
|
&gc.AmountRemaining,
|
||||||
@@ -125,6 +204,8 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
|
|||||||
&gc.CreatedAt,
|
&gc.CreatedAt,
|
||||||
&redeemedAt,
|
&redeemedAt,
|
||||||
&redeemedBy,
|
&redeemedBy,
|
||||||
|
&gc.IsInventory,
|
||||||
|
&lastUsedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to scan gift card: %v", err)
|
log.Printf("Failed to scan gift card: %v", err)
|
||||||
@@ -141,17 +222,45 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
|
|||||||
if redeemedAt.Valid {
|
if redeemedAt.Valid {
|
||||||
gc.RedeemedAt = &redeemedAt.Time
|
gc.RedeemedAt = &redeemedAt.Time
|
||||||
}
|
}
|
||||||
|
if lastUsedAt.Valid {
|
||||||
|
gc.LastUsedAt = &lastUsedAt.Time
|
||||||
|
}
|
||||||
|
|
||||||
summary.GiftCards = append(summary.GiftCards, gc)
|
resp.GiftCards = append(resp.GiftCards, gc)
|
||||||
}
|
}
|
||||||
|
|
||||||
summary.UserBalances = []UserBalance{}
|
// --- User balances (all, optionally filtered) ---
|
||||||
ubRows, err := db.DB.Query(ctx, `
|
|
||||||
SELECT b.user_id, u.n_first_name || ' ' || u.n_last_name AS name, u.email, b.balance, b.updated_at
|
var ubTotal int
|
||||||
FROM user_giftcard_balances b
|
var ubListQuery string
|
||||||
JOIN users u ON b.user_id = u.id
|
var ubListArgs []interface{}
|
||||||
ORDER BY b.updated_at DESC
|
|
||||||
`)
|
if searchTerm != "" {
|
||||||
|
searchPattern := "%" + searchTerm + "%"
|
||||||
|
|
||||||
|
ubListQuery = `
|
||||||
|
SELECT COUNT(*) OVER() AS total_count,
|
||||||
|
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 COUNT(*) OVER() AS total_count,
|
||||||
|
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 {
|
if err != nil {
|
||||||
log.Printf("Failed to query user balances: %v", err)
|
log.Printf("Failed to query user balances: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
@@ -161,7 +270,9 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
for ubRows.Next() {
|
for ubRows.Next() {
|
||||||
var ub UserBalance
|
var ub UserBalance
|
||||||
|
var rowTotal int
|
||||||
err = ubRows.Scan(
|
err = ubRows.Scan(
|
||||||
|
&rowTotal,
|
||||||
&ub.UserID,
|
&ub.UserID,
|
||||||
&ub.Name,
|
&ub.Name,
|
||||||
&ub.Email,
|
&ub.Email,
|
||||||
@@ -173,11 +284,23 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
summary.UserBalances = append(summary.UserBalances, ub)
|
if ubTotal == 0 {
|
||||||
|
ubTotal = rowTotal
|
||||||
|
}
|
||||||
|
resp.UserBalances = append(resp.UserBalances, ub)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resp.UBTotal = ubTotal
|
||||||
|
resp.Total = gcTotal
|
||||||
|
|
||||||
|
totalPages := (gcTotal + perPage - 1) / perPage
|
||||||
|
if totalPages == 0 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
|
resp.TotalPages = totalPages
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(summary)
|
json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateGiftCard(w http.ResponseWriter, r *http.Request) {
|
func CreateGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -190,7 +313,11 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Amount <= 0 {
|
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)
|
http.Error(w, "Amount must be greater than zero", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -204,16 +331,19 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer tx.Rollback(ctx)
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
var gc GiftCard
|
var gc GiftCard
|
||||||
|
var lastUsedAt sql.NullTime
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at)
|
||||||
VALUES ($1, $1, $2)
|
VALUES ($1, $1, $2, $3, NOW())
|
||||||
RETURNING id, total_funds_added, amount_remaining, created_by, created_at
|
RETURNING id, total_funds_added, amount_remaining, created_by, created_at, is_inventory, last_used_at
|
||||||
`, req.Amount, adminID).Scan(
|
`, req.Amount, adminID, req.IsInventory).Scan(
|
||||||
&gc.ID,
|
&gc.ID,
|
||||||
&gc.TotalFundsAdded,
|
&gc.TotalFundsAdded,
|
||||||
&gc.AmountRemaining,
|
&gc.AmountRemaining,
|
||||||
&gc.CreatedBy,
|
&gc.CreatedBy,
|
||||||
&gc.CreatedAt,
|
&gc.CreatedAt,
|
||||||
|
&gc.IsInventory,
|
||||||
|
&lastUsedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to create gift card: %v", err)
|
log.Printf("Failed to create gift card: %v", err)
|
||||||
@@ -221,13 +351,24 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create an 'on_the_house' payment record for financial tracking
|
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, `
|
_, err = tx.Exec(ctx, `
|
||||||
INSERT INTO payments (payment_type, payment_method, status, amount, created_by, created_at, updated_at)
|
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
|
||||||
VALUES ('full', 'on_the_house', 'completed', $1, $2, NOW(), NOW())
|
VALUES ($1, 'purchase', $2, 'api', NULL, $3, $4)
|
||||||
`, req.Amount, adminID)
|
`, gc.ID, req.Amount, adminID, notes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to create payment record for gift card: %v", err)
|
log.Printf("Failed to record gift card transaction: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -245,6 +386,7 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func TopUpGiftCard(w http.ResponseWriter, r *http.Request) {
|
func TopUpGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
adminID, _ := ctx.Value(mw.UserIDKey).(string)
|
||||||
cardID := chi.URLParam(r, "id")
|
cardID := chi.URLParam(r, "id")
|
||||||
if cardID == "" || !validators.IsValidID(cardID) {
|
if cardID == "" || !validators.IsValidID(cardID) {
|
||||||
http.Error(w, "Invalid gift card ID", http.StatusBadRequest)
|
http.Error(w, "Invalid gift card ID", http.StatusBadRequest)
|
||||||
@@ -262,6 +404,18 @@ func TopUpGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
tx, err := db.DB.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to begin transaction: %v", err)
|
log.Printf("Failed to begin transaction: %v", err)
|
||||||
@@ -271,7 +425,9 @@ func TopUpGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer tx.Rollback(ctx)
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
var redeemedBy sql.NullString
|
var redeemedBy sql.NullString
|
||||||
err = tx.QueryRow(ctx, "SELECT redeemed_by FROM gift_cards WHERE id = $1", cardID).Scan(&redeemedBy)
|
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 err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Gift card not found", http.StatusNotFound)
|
http.Error(w, "Gift card not found", http.StatusNotFound)
|
||||||
@@ -287,12 +443,21 @@ func TopUpGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 gc GiftCard
|
||||||
var createdBy sql.NullString
|
var createdBy sql.NullString
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
UPDATE gift_cards
|
UPDATE gift_cards
|
||||||
SET total_funds_added = total_funds_added + $1,
|
SET total_funds_added = total_funds_added + $1,
|
||||||
amount_remaining = amount_remaining + $1
|
amount_remaining = amount_remaining + $1,
|
||||||
|
last_used_at = NOW()
|
||||||
WHERE id = $2
|
WHERE id = $2
|
||||||
RETURNING id, total_funds_added, amount_remaining, created_by, created_at
|
RETURNING id, total_funds_added, amount_remaining, created_by, created_at
|
||||||
`, req.Amount, cardID).Scan(
|
`, req.Amount, cardID).Scan(
|
||||||
@@ -312,6 +477,16 @@ func TopUpGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
gc.CreatedBy = &createdBy.String
|
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 {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
log.Printf("Failed to commit transaction: %v", err)
|
log.Printf("Failed to commit transaction: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
@@ -397,7 +572,8 @@ func TransferGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
_, err = tx.Exec(ctx, `
|
_, err = tx.Exec(ctx, `
|
||||||
UPDATE gift_cards
|
UPDATE gift_cards
|
||||||
SET amount_remaining = amount_remaining - $1
|
SET amount_remaining = amount_remaining - $1,
|
||||||
|
last_used_at = NOW()
|
||||||
WHERE id = $2
|
WHERE id = $2
|
||||||
`, req.Amount, fromCardID)
|
`, req.Amount, fromCardID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -409,7 +585,8 @@ func TransferGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, err = tx.Exec(ctx, `
|
_, err = tx.Exec(ctx, `
|
||||||
UPDATE gift_cards
|
UPDATE gift_cards
|
||||||
SET amount_remaining = amount_remaining + $1,
|
SET amount_remaining = amount_remaining + $1,
|
||||||
total_funds_added = total_funds_added + $1
|
total_funds_added = total_funds_added + $1,
|
||||||
|
last_used_at = NOW()
|
||||||
WHERE id = $2
|
WHERE id = $2
|
||||||
`, req.Amount, req.ToCardID)
|
`, req.Amount, req.ToCardID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -511,6 +688,16 @@ func RedeemGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
log.Printf("Failed to commit transaction: %v", err)
|
log.Printf("Failed to commit transaction: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
@@ -607,6 +794,18 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
paymentService := NewPaymentService()
|
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 sourceID string
|
||||||
var savedCardID *string
|
var savedCardID *string
|
||||||
|
|
||||||
@@ -671,11 +870,12 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
var cardID string
|
var cardID string
|
||||||
|
|
||||||
if req.RecipientType == "self" {
|
if req.RecipientType == "self" {
|
||||||
|
expiryDate := time.Now().AddDate(1, 0, 0)
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by)
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by, is_inventory, expiry_date)
|
||||||
VALUES ($1, 0, $2, NOW(), $2)
|
VALUES ($1, 0, $2, NOW(), $2, FALSE, $3)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, amountPounds, userID).Scan(&cardID)
|
`, amountPounds, userID, expiryDate).Scan(&cardID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to insert gift card: %v", err)
|
log.Printf("Failed to insert gift card: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
@@ -694,12 +894,23 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
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 {
|
} else {
|
||||||
|
expiryDate := time.Now().AddDate(1, 0, 0)
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, expiry_date)
|
||||||
VALUES ($1, $1, $2)
|
VALUES ($1, $1, $2, FALSE, $3)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, amountPounds, userID).Scan(&cardID)
|
`, amountPounds, userID, expiryDate).Scan(&cardID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to insert gift card: %v", err)
|
log.Printf("Failed to insert gift card: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
@@ -713,6 +924,16 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
recipient = userEmail
|
recipient = userEmail
|
||||||
}
|
}
|
||||||
log.Printf("[TODO EMAIL] Send gift card code %s (Value: £%.2f) to %s", cardID, amountPounds, recipient)
|
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")
|
fees := paymentService.CalculateFees(req.Amount, "online")
|
||||||
@@ -766,3 +987,126 @@ func normalizeCode(code string) string {
|
|||||||
}
|
}
|
||||||
return clean
|
return clean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,7 +87,10 @@ func TestAdminTopUpGiftCard(t *testing.T) {
|
|||||||
t.Fatalf("failed to insert gift card: %v", err)
|
t.Fatalf("failed to insert gift card: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
reqBody, _ := json.Marshal(map[string]interface{}{"amount": 25.00})
|
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"amount": 25.00,
|
||||||
|
"payment_method": "on_the_house",
|
||||||
|
})
|
||||||
req := httptest.NewRequest("PUT", "/api/admin/gift-cards/"+cardID+"/topup", bytes.NewBuffer(reqBody))
|
req := httptest.NewRequest("PUT", "/api/admin/gift-cards/"+cardID+"/topup", bytes.NewBuffer(reqBody))
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -498,3 +501,623 @@ func TestAdminRecordPayment_CashAndGiftCard(t *testing.T) {
|
|||||||
t.Errorf("expected user balance to be 35.00, got %.2f", userBalance)
|
t.Errorf("expected user balance to be 35.00, got %.2f", userBalance)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- New Tests for branch features ---
|
||||||
|
|
||||||
|
func TestGetExpiredBalances(t *testing.T) {
|
||||||
|
resetGiftCardsTestData(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
adminID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
|
||||||
|
|
||||||
|
token := jwt.GenerateTestToken(adminID, "admin")
|
||||||
|
|
||||||
|
// Seed expired balances
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
_, err = db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at)
|
||||||
|
VALUES ($1, $2, NOW() - interval '30 days')
|
||||||
|
`, adminID, float64(25.00*(i+1)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to seed expired balance %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/admin/gift-cards/expired-balances", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(mw.RequireAuth)
|
||||||
|
r.Get("/api/admin/gift-cards/expired-balances", GetExpiredBalances)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
total, ok := resp["total"].(float64)
|
||||||
|
if !ok || total != 2 {
|
||||||
|
t.Errorf("expected total 2, got %v", resp["total"])
|
||||||
|
}
|
||||||
|
|
||||||
|
balances, ok := resp["expired_balances"].([]interface{})
|
||||||
|
if !ok || len(balances) != 2 {
|
||||||
|
t.Errorf("expected 2 expired_balances, got %d", len(balances))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimExpiredBalance_HappyPath(t *testing.T) {
|
||||||
|
resetGiftCardsTestData(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
adminID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
|
||||||
|
|
||||||
|
token := jwt.GenerateTestToken(adminID, "admin")
|
||||||
|
|
||||||
|
// Seed an expired balance with known ID
|
||||||
|
var balanceID string
|
||||||
|
err = db.DB.QueryRow(ctx, `
|
||||||
|
INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at)
|
||||||
|
VALUES ($1, 50.00, NOW() - interval '30 days')
|
||||||
|
RETURNING id
|
||||||
|
`, adminID).Scan(&balanceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to seed expired balance: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
notes := "claimed via test"
|
||||||
|
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"balance_id": balanceID,
|
||||||
|
"notes": notes,
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("POST", "/api/admin/gift-cards/expired-balances/claim", bytes.NewBuffer(reqBody))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(mw.RequireAuth)
|
||||||
|
r.Post("/api/admin/gift-cards/expired-balances/claim", ClaimExpiredBalance)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d. Body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]string
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if resp["status"] != "claimed" {
|
||||||
|
t.Errorf("expected status 'claimed', got '%s'", resp["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify claimed_at is set in DB
|
||||||
|
var claimedAt sql.NullTime
|
||||||
|
err = db.DB.QueryRow(ctx, "SELECT claimed_at FROM gift_card_expired_balances WHERE id = $1", balanceID).Scan(&claimedAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query expired balance: %v", err)
|
||||||
|
}
|
||||||
|
if !claimedAt.Valid {
|
||||||
|
t.Error("expected claimed_at to be set, got null")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimExpiredBalance_AlreadyClaimed(t *testing.T) {
|
||||||
|
resetGiftCardsTestData(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
adminID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
|
||||||
|
|
||||||
|
token := jwt.GenerateTestToken(adminID, "admin")
|
||||||
|
|
||||||
|
// Seed an expired balance that is already claimed
|
||||||
|
var balanceID string
|
||||||
|
err = db.DB.QueryRow(ctx, `
|
||||||
|
INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at, claimed_at, claimed_by_admin)
|
||||||
|
VALUES ($1, 50.00, NOW() - interval '30 days', NOW(), $2)
|
||||||
|
RETURNING id
|
||||||
|
`, adminID, adminID).Scan(&balanceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to seed claimed expired balance: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"balance_id": balanceID,
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("POST", "/api/admin/gift-cards/expired-balances/claim", bytes.NewBuffer(reqBody))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(mw.RequireAuth)
|
||||||
|
r.Post("/api/admin/gift-cards/expired-balances/claim", ClaimExpiredBalance)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusConflict {
|
||||||
|
t.Errorf("expected 409, got %d. Body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimExpiredBalance_NotFound(t *testing.T) {
|
||||||
|
resetGiftCardsTestData(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
adminID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
|
||||||
|
|
||||||
|
token := jwt.GenerateTestToken(adminID, "admin")
|
||||||
|
|
||||||
|
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"balance_id": "aaaaaaaaaaaa",
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("POST", "/api/admin/gift-cards/expired-balances/claim", bytes.NewBuffer(reqBody))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(mw.RequireAuth)
|
||||||
|
r.Post("/api/admin/gift-cards/expired-balances/claim", ClaimExpiredBalance)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("expected 404, got %d. Body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetGiftCards_Pagination(t *testing.T) {
|
||||||
|
resetGiftCardsTestData(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
adminID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
|
||||||
|
|
||||||
|
token := jwt.GenerateTestToken(adminID, "admin")
|
||||||
|
|
||||||
|
// Create 15 gift cards
|
||||||
|
for i := 0; i < 15; i++ {
|
||||||
|
_, err = db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
|
||||||
|
VALUES (10.00, 10.00, $1)
|
||||||
|
`, adminID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create gift card %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request page 1 with per_page=5
|
||||||
|
req := httptest.NewRequest("GET", "/api/admin/gift-cards?page=1&per_page=5", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(mw.RequireAuth)
|
||||||
|
r.Get("/api/admin/gift-cards", GetGiftCards)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d. Body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp GiftCardListResponse
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.GiftCards) != 5 {
|
||||||
|
t.Errorf("expected 5 gift cards on page 1, got %d", len(resp.GiftCards))
|
||||||
|
}
|
||||||
|
if resp.Total != 15 {
|
||||||
|
t.Errorf("expected total 15, got %d", resp.Total)
|
||||||
|
}
|
||||||
|
if resp.Page != 1 {
|
||||||
|
t.Errorf("expected page 1, got %d", resp.Page)
|
||||||
|
}
|
||||||
|
if resp.PerPage != 5 {
|
||||||
|
t.Errorf("expected perPage 5, got %d", resp.PerPage)
|
||||||
|
}
|
||||||
|
if resp.TotalPages != 3 {
|
||||||
|
t.Errorf("expected totalPages 3, got %d", resp.TotalPages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request page 3 to verify last page
|
||||||
|
req3 := httptest.NewRequest("GET", "/api/admin/gift-cards?page=3&per_page=5", nil)
|
||||||
|
req3.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
w3 := httptest.NewRecorder()
|
||||||
|
r3 := chi.NewRouter()
|
||||||
|
r3.Use(mw.RequireAuth)
|
||||||
|
r3.Get("/api/admin/gift-cards", GetGiftCards)
|
||||||
|
r3.ServeHTTP(w3, req3)
|
||||||
|
|
||||||
|
if w3.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d", w3.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp3 GiftCardListResponse
|
||||||
|
if err := json.NewDecoder(w3.Body).Decode(&resp3); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp3.GiftCards) != 5 {
|
||||||
|
t.Errorf("expected 5 gift cards on page 3, got %d", len(resp3.GiftCards))
|
||||||
|
}
|
||||||
|
if resp3.Total != 15 {
|
||||||
|
t.Errorf("expected total 15 on page 3, got %d", resp3.Total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetGiftCards_Search(t *testing.T) {
|
||||||
|
resetGiftCardsTestData(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
adminID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
|
||||||
|
|
||||||
|
token := jwt.GenerateTestToken(adminID, "admin")
|
||||||
|
|
||||||
|
// Create cards with specific hex IDs for search predictability
|
||||||
|
searchableID := "aaaaaabbbbcc"
|
||||||
|
nonSearchableID := "ddddeeeeffff"
|
||||||
|
|
||||||
|
_, err = db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO gift_cards (id, total_funds_added, amount_remaining, created_by)
|
||||||
|
VALUES ($1, 10.00, 10.00, $2)
|
||||||
|
`, searchableID, adminID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create searchable card: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO gift_cards (id, total_funds_added, amount_remaining, created_by)
|
||||||
|
VALUES ($1, 20.00, 20.00, $2)
|
||||||
|
`, nonSearchableID, adminID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create non-searchable card: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search by partial ID match
|
||||||
|
req := httptest.NewRequest("GET", "/api/admin/gift-cards?q=aaaa", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(mw.RequireAuth)
|
||||||
|
r.Get("/api/admin/gift-cards", GetGiftCards)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d. Body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp GiftCardListResponse
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Total != 1 {
|
||||||
|
t.Errorf("expected total 1, got %d", resp.Total)
|
||||||
|
}
|
||||||
|
if len(resp.GiftCards) != 1 {
|
||||||
|
t.Errorf("expected 1 gift card in results, got %d", len(resp.GiftCards))
|
||||||
|
}
|
||||||
|
if len(resp.GiftCards) > 0 && resp.GiftCards[0].ID != searchableID {
|
||||||
|
t.Errorf("expected card ID %s, got %s", searchableID, resp.GiftCards[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateGiftCard_NegativeAmount(t *testing.T) {
|
||||||
|
resetGiftCardsTestData(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
adminID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
|
||||||
|
|
||||||
|
token := jwt.GenerateTestToken(adminID, "admin")
|
||||||
|
|
||||||
|
reqBody, _ := json.Marshal(map[string]interface{}{"amount": -10.00})
|
||||||
|
req := httptest.NewRequest("POST", "/api/admin/gift-cards", bytes.NewBuffer(reqBody))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(mw.RequireAuth)
|
||||||
|
r.Post("/api/admin/gift-cards", CreateGiftCard)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for negative amount, got %d. Body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateGiftCard_ZeroAmountNoInventory(t *testing.T) {
|
||||||
|
resetGiftCardsTestData(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
adminID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
|
||||||
|
|
||||||
|
token := jwt.GenerateTestToken(adminID, "admin")
|
||||||
|
|
||||||
|
reqBody, _ := json.Marshal(map[string]interface{}{"amount": 0, "is_inventory": false})
|
||||||
|
req := httptest.NewRequest("POST", "/api/admin/gift-cards", bytes.NewBuffer(reqBody))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(mw.RequireAuth)
|
||||||
|
r.Post("/api/admin/gift-cards", CreateGiftCard)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for zero amount without inventory, got %d. Body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateGiftCard_ZeroAmountInventory(t *testing.T) {
|
||||||
|
resetGiftCardsTestData(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
adminID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
|
||||||
|
|
||||||
|
token := jwt.GenerateTestToken(adminID, "admin")
|
||||||
|
|
||||||
|
reqBody, _ := json.Marshal(map[string]interface{}{"amount": 0, "is_inventory": true})
|
||||||
|
req := httptest.NewRequest("POST", "/api/admin/gift-cards", bytes.NewBuffer(reqBody))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(mw.RequireAuth)
|
||||||
|
r.Post("/api/admin/gift-cards", CreateGiftCard)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Errorf("expected 201 for inventory card with zero amount, got %d. Body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var gc GiftCard
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&gc); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !gc.IsInventory {
|
||||||
|
t.Error("expected card to be inventory card")
|
||||||
|
}
|
||||||
|
if gc.TotalFundsAdded != 0 || gc.AmountRemaining != 0 {
|
||||||
|
t.Errorf("expected zero balance card, got added=%.2f remaining=%.2f", gc.TotalFundsAdded, gc.AmountRemaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTopUpGiftCard_InvalidPaymentMethod(t *testing.T) {
|
||||||
|
resetGiftCardsTestData(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
adminID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
|
||||||
|
|
||||||
|
token := jwt.GenerateTestToken(adminID, "admin")
|
||||||
|
|
||||||
|
// Create gift card
|
||||||
|
var cardID string
|
||||||
|
err = db.DB.QueryRow(ctx, `
|
||||||
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
|
||||||
|
VALUES (50.00, 50.00, $1)
|
||||||
|
RETURNING id
|
||||||
|
`, adminID).Scan(&cardID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to insert gift card: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"amount": 25.00,
|
||||||
|
"payment_method": "invalid_method",
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("PUT", "/api/admin/gift-cards/"+cardID+"/topup", bytes.NewBuffer(reqBody))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(mw.RequireAuth)
|
||||||
|
r.Put("/api/admin/gift-cards/{id}/topup", TopUpGiftCard)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for invalid payment method, got %d. Body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTopUpGiftCard_RedeemedCard(t *testing.T) {
|
||||||
|
resetGiftCardsTestData(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
adminID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
|
||||||
|
|
||||||
|
token := jwt.GenerateTestToken(adminID, "admin")
|
||||||
|
|
||||||
|
// Create a card that's already redeemed
|
||||||
|
var cardID string
|
||||||
|
err = db.DB.QueryRow(ctx, `
|
||||||
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by)
|
||||||
|
VALUES (50.00, 0, $1, NOW(), $1)
|
||||||
|
RETURNING id
|
||||||
|
`, adminID).Scan(&cardID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to insert redeemed gift card: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"amount": 25.00,
|
||||||
|
"payment_method": "on_the_house",
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("PUT", "/api/admin/gift-cards/"+cardID+"/topup", bytes.NewBuffer(reqBody))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(mw.RequireAuth)
|
||||||
|
r.Put("/api/admin/gift-cards/{id}/topup", TopUpGiftCard)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for redeemed card topup, got %d. Body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuyGiftCard_Idempotency(t *testing.T) {
|
||||||
|
resetGiftCardsTestData(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
userID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.GenerateTestToken(userID, "verified_email")
|
||||||
|
|
||||||
|
idempotencyKey := "idempotent-buy-gc-test"
|
||||||
|
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"amount": 2000,
|
||||||
|
"recipient_type": "self",
|
||||||
|
"new_card_token": "cnon:card-nonce-ok",
|
||||||
|
"idempotency_key": idempotencyKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send request
|
||||||
|
body1, _ := json.Marshal(reqBody)
|
||||||
|
req1 := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(body1))
|
||||||
|
req1.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req1.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w1 := httptest.NewRecorder()
|
||||||
|
r1 := chi.NewRouter()
|
||||||
|
r1.Use(mw.RequireAuth)
|
||||||
|
r1.Post("/api/user/giftcards/buy", BuyGiftCard)
|
||||||
|
r1.ServeHTTP(w1, req1)
|
||||||
|
|
||||||
|
if w1.Code != http.StatusCreated && w1.Code != http.StatusOK {
|
||||||
|
t.Errorf("buy request: expected 201 or 200, got %d. Body: %s", w1.Code, w1.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify exactly one payment record was created for this key
|
||||||
|
var payCount int
|
||||||
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE idempotency_key = $1", idempotencyKey).Scan(&payCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query payments: %v", err)
|
||||||
|
}
|
||||||
|
if payCount != 1 {
|
||||||
|
t.Errorf("expected 1 payment record for idempotency key, got %d", payCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify exactly one user balance record
|
||||||
|
var balCount int
|
||||||
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query user balances: %v", err)
|
||||||
|
}
|
||||||
|
if balCount != 1 {
|
||||||
|
t.Errorf("expected 1 user balance record, got %d", balCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify exactly one gift card was created for self-purchase (amount_remaining=0, redeemed)
|
||||||
|
var cardCount int
|
||||||
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE redeemed_by = $1", userID).Scan(&cardCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query gift cards: %v", err)
|
||||||
|
}
|
||||||
|
if cardCount != 1 {
|
||||||
|
t.Errorf("expected 1 gift card record, got %d", cardCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send second request with same idempotency key
|
||||||
|
body2, _ := json.Marshal(reqBody)
|
||||||
|
req2 := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(body2))
|
||||||
|
req2.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req2.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
r2 := chi.NewRouter()
|
||||||
|
r2.Use(mw.RequireAuth)
|
||||||
|
r2.Post("/api/user/giftcards/buy", BuyGiftCard)
|
||||||
|
r2.ServeHTTP(w2, req2)
|
||||||
|
|
||||||
|
// Verify counts remain unchanged (idempotent)
|
||||||
|
var payCount2 int
|
||||||
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE idempotency_key = $1", idempotencyKey).Scan(&payCount2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query payments: %v", err)
|
||||||
|
}
|
||||||
|
if payCount2 != 1 {
|
||||||
|
t.Errorf("after second request: expected 1 payment record, got %d", payCount2)
|
||||||
|
}
|
||||||
|
|
||||||
|
var balCount2 int
|
||||||
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balCount2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query user balances: %v", err)
|
||||||
|
}
|
||||||
|
if balCount2 != 1 {
|
||||||
|
t.Errorf("after second request: expected 1 user balance record, got %d", balCount2)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cardCount2 int
|
||||||
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE redeemed_by = $1", userID).Scan(&cardCount2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query gift cards: %v", err)
|
||||||
|
}
|
||||||
|
if cardCount2 != 1 {
|
||||||
|
t.Errorf("after second request: expected 1 gift card record, got %d", cardCount2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Deduct directly from card remaining amount
|
// Deduct directly from card remaining amount
|
||||||
_, err = tx.Exec(r.Context(), "UPDATE gift_cards SET amount_remaining = amount_remaining - $1 WHERE id = $2", amountPounds, cleanCardID)
|
_, err = tx.Exec(r.Context(), "UPDATE gift_cards SET amount_remaining = amount_remaining - $1, last_used_at = NOW() WHERE id = $2", amountPounds, cleanCardID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to deduct gift card amount: %v", err)
|
log.Printf("Failed to deduct gift card amount: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
@@ -366,7 +366,7 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
paymentID, err := service.CreatePaymentRecord(r.Context(), record)
|
paymentID, err := service.CreatePaymentRecord(r.Context(), record, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to create payment record: %v", err)
|
log.Printf("Failed to create payment record: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
@@ -540,7 +540,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
CreatedBy: &userID,
|
CreatedBy: &userID,
|
||||||
}
|
}
|
||||||
|
|
||||||
paymentID, err := service.CreatePaymentRecord(r.Context(), record)
|
paymentID, err := service.CreatePaymentRecord(r.Context(), record, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to create payment record: %v", err)
|
log.Printf("Failed to create payment record: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
@@ -879,7 +879,7 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
CreatedBy: &userID,
|
CreatedBy: &userID,
|
||||||
}
|
}
|
||||||
|
|
||||||
paymentID, err := service.CreatePaymentRecord(r.Context(), record)
|
paymentID, err := service.CreatePaymentRecord(r.Context(), record, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to create payment record: %v", err)
|
log.Printf("Failed to create payment record: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -1407,7 +1407,7 @@ func TestGetBookingRemainingBalanceCents(t *testing.T) {
|
|||||||
PaymentMethod: "cash",
|
PaymentMethod: "cash",
|
||||||
Status: "completed",
|
Status: "completed",
|
||||||
Amount: 20.00,
|
Amount: 20.00,
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create payment: %v", err)
|
t.Fatalf("failed to create payment: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1426,7 +1426,7 @@ func TestGetBookingRemainingBalanceCents(t *testing.T) {
|
|||||||
PaymentMethod: "cash",
|
PaymentMethod: "cash",
|
||||||
Status: "completed",
|
Status: "completed",
|
||||||
Amount: float64(afterPartial) / 100.0,
|
Amount: float64(afterPartial) / 100.0,
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create payment: %v", err)
|
t.Fatalf("failed to create payment: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ type PaymentRecord struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
CreatedBy *string
|
CreatedBy *string
|
||||||
|
GiftCardID *string
|
||||||
}
|
}
|
||||||
|
|
||||||
type RefundRecord struct {
|
type RefundRecord struct {
|
||||||
@@ -81,7 +82,7 @@ func (s *PaymentService) CalculateFees(amount int64, method string) float64 {
|
|||||||
return float64(amount * 175 / 10000) / 100.0
|
return float64(amount * 175 / 10000) / 100.0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PaymentService) CreatePaymentRecord(ctx context.Context, record PaymentRecord) (string, error) {
|
func (s *PaymentService) CreatePaymentRecord(ctx context.Context, record PaymentRecord, giftCardID *string) (string, error) {
|
||||||
var bookingID *string
|
var bookingID *string
|
||||||
if record.BookingID != "" {
|
if record.BookingID != "" {
|
||||||
bookingID = &record.BookingID
|
bookingID = &record.BookingID
|
||||||
@@ -92,8 +93,9 @@ func (s *PaymentService) CreatePaymentRecord(ctx context.Context, record Payment
|
|||||||
INSERT INTO payments (
|
INSERT INTO payments (
|
||||||
booking_id, payment_type, payment_method, vendor_code, invoice_number,
|
booking_id, payment_type, payment_method, vendor_code, invoice_number,
|
||||||
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
|
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
|
||||||
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by
|
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
gift_card_id
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
bookingID,
|
bookingID,
|
||||||
@@ -114,6 +116,7 @@ func (s *PaymentService) CreatePaymentRecord(ctx context.Context, record Payment
|
|||||||
record.CreatedAt,
|
record.CreatedAt,
|
||||||
record.UpdatedAt,
|
record.UpdatedAt,
|
||||||
record.CreatedBy,
|
record.CreatedBy,
|
||||||
|
giftCardID,
|
||||||
).Scan(&id)
|
).Scan(&id)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -170,7 +173,8 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
|
|||||||
rows, err := db.DB.Query(ctx, `
|
rows, err := db.DB.Query(ctx, `
|
||||||
SELECT p.id, p.booking_id, p.payment_type, p.payment_method, p.vendor_code, p.invoice_number,
|
SELECT p.id, p.booking_id, p.payment_type, p.payment_method, p.vendor_code, p.invoice_number,
|
||||||
p.status, p.amount, COALESCE(usc.last_4, ''), p.is_vat_applicable, p.vat_rate, p.vat_amount, p.net_amount,
|
p.status, p.amount, COALESCE(usc.last_4, ''), p.is_vat_applicable, p.vat_rate, p.vat_amount, p.net_amount,
|
||||||
p.user_saved_card_id, p.square_payment_id, p.idempotency_key, p.fees, p.created_at, p.updated_at, p.created_by
|
p.user_saved_card_id, p.square_payment_id, p.idempotency_key, p.fees, p.created_at, p.updated_at, p.created_by,
|
||||||
|
p.gift_card_id
|
||||||
FROM payments p
|
FROM payments p
|
||||||
LEFT JOIN user_saved_cards usc ON p.user_saved_card_id = usc.id
|
LEFT JOIN user_saved_cards usc ON p.user_saved_card_id = usc.id
|
||||||
WHERE p.booking_id = $1
|
WHERE p.booking_id = $1
|
||||||
@@ -189,7 +193,7 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
|
|||||||
&p.ID, &p.BookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber,
|
&p.ID, &p.BookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber,
|
||||||
&p.Status, &p.Amount, &p.CardLast4, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount,
|
&p.Status, &p.Amount, &p.CardLast4, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount,
|
||||||
&p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees,
|
&p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees,
|
||||||
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy,
|
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &p.GiftCardID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -237,14 +241,40 @@ func (s *PaymentService) CheckIdempotency(ctx context.Context, bookingID, idempo
|
|||||||
err := db.DB.QueryRow(ctx, `
|
err := db.DB.QueryRow(ctx, `
|
||||||
SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number,
|
SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number,
|
||||||
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
|
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
|
||||||
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by
|
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by,
|
||||||
|
gift_card_id
|
||||||
FROM payments
|
FROM payments
|
||||||
WHERE booking_id = $1 AND idempotency_key = $2
|
WHERE booking_id = $1 AND idempotency_key = $2
|
||||||
`, bookingID, idempotencyKey).Scan(
|
`, bookingID, idempotencyKey).Scan(
|
||||||
&p.ID, &p.BookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber,
|
&p.ID, &p.BookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber,
|
||||||
&p.Status, &p.Amount, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount,
|
&p.Status, &p.Amount, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount,
|
||||||
&p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees,
|
&p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees,
|
||||||
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy,
|
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &p.GiftCardID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PaymentService) CheckIdempotencyByKey(ctx context.Context, idempotencyKey string) (*PaymentRecord, error) {
|
||||||
|
var p PaymentRecord
|
||||||
|
err := db.DB.QueryRow(ctx, `
|
||||||
|
SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number,
|
||||||
|
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
|
||||||
|
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by,
|
||||||
|
gift_card_id
|
||||||
|
FROM payments
|
||||||
|
WHERE idempotency_key = $1
|
||||||
|
`, idempotencyKey).Scan(
|
||||||
|
&p.ID, &p.BookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber,
|
||||||
|
&p.Status, &p.Amount, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount,
|
||||||
|
&p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees,
|
||||||
|
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &p.GiftCardID,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -261,14 +291,15 @@ func (s *PaymentService) GetPaymentByID(ctx context.Context, paymentID string) (
|
|||||||
err := db.DB.QueryRow(ctx, `
|
err := db.DB.QueryRow(ctx, `
|
||||||
SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number,
|
SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number,
|
||||||
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
|
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
|
||||||
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by
|
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by,
|
||||||
|
gift_card_id
|
||||||
FROM payments
|
FROM payments
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, paymentID).Scan(
|
`, paymentID).Scan(
|
||||||
&p.ID, &p.BookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber,
|
&p.ID, &p.BookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber,
|
||||||
&p.Status, &p.Amount, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount,
|
&p.Status, &p.Amount, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount,
|
||||||
&p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees,
|
&p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees,
|
||||||
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy,
|
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &p.GiftCardID,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Amount must be greater than zero", http.StatusBadRequest)
|
http.Error(w, "Amount must be greater than zero", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.PaymentMethod != "cash" && req.PaymentMethod != "card_machine" && req.PaymentMethod != "saved_card" && req.PaymentMethod != "online_square" {
|
if req.PaymentMethod != "cash" && req.PaymentMethod != "card_machine" && req.PaymentMethod != "saved_card" && req.PaymentMethod != "online_square" && req.PaymentMethod != "on_the_house" {
|
||||||
http.Error(w, "Payment method must be 'cash', 'card_machine', 'saved_card', or 'online_square'", http.StatusBadRequest)
|
http.Error(w, "Payment method must be 'cash', 'card_machine', 'saved_card', 'online_square', or 'on_the_house'", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.PaymentMethod == "saved_card" && (req.UserSavedCardID == nil || *req.UserSavedCardID == "") {
|
if req.PaymentMethod == "saved_card" && (req.UserSavedCardID == nil || *req.UserSavedCardID == "") {
|
||||||
@@ -81,6 +81,31 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Idempotency check: if key provided, return existing sale if found
|
||||||
|
if req.IdempotencyKey != "" {
|
||||||
|
var existingID string
|
||||||
|
err := db.DB.QueryRow(ctx, `SELECT id FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&existingID)
|
||||||
|
if err == nil {
|
||||||
|
// Existing sale found — return it (idempotent)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(TillSaleResponse{
|
||||||
|
ID: existingID,
|
||||||
|
ItemType: req.ItemType,
|
||||||
|
TotalAmount: req.Amount,
|
||||||
|
PaymentMethod: req.PaymentMethod,
|
||||||
|
Status: "completed",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiryMonths int
|
||||||
|
err := db.DB.QueryRow(ctx, `SELECT COALESCE(gift_card_expiry_months, 12) FROM business_settings LIMIT 1`).Scan(&expiryMonths)
|
||||||
|
if err != nil {
|
||||||
|
expiryMonths = 12
|
||||||
|
}
|
||||||
|
expiryDate := time.Now().AddDate(0, expiryMonths, 0)
|
||||||
|
|
||||||
service := NewPaymentService()
|
service := NewPaymentService()
|
||||||
|
|
||||||
tx, err := db.DB.Begin(ctx)
|
tx, err := db.DB.Begin(ctx)
|
||||||
@@ -94,15 +119,25 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
var giftCardID string
|
var giftCardID string
|
||||||
if req.Action == "create" {
|
if req.Action == "create" {
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, expiry_date)
|
||||||
VALUES ($1, $1, $2)
|
VALUES ($1, $1, $2, FALSE, $3)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, req.Amount, adminID).Scan(&giftCardID)
|
`, req.Amount, adminID, expiryDate).Scan(&giftCardID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to create gift card: %v", err)
|
log.Printf("Failed to create gift card: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
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, 'till_sale', NULL, $3, NULL)
|
||||||
|
`, giftCardID, req.Amount, req.UserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create gift_card_transaction: %v", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
cardID := normalizeCode(*req.GiftCardID)
|
cardID := normalizeCode(*req.GiftCardID)
|
||||||
var redeemedBy sql.NullString
|
var redeemedBy sql.NullString
|
||||||
@@ -121,10 +156,20 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isInventory bool
|
||||||
|
var previousTotal float64
|
||||||
|
err = tx.QueryRow(ctx, `SELECT is_inventory, total_funds_added FROM gift_cards WHERE id = $1`, cardID).Scan(&isInventory, &previousTotal)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to check gift card state: %v", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, `
|
_, err = tx.Exec(ctx, `
|
||||||
UPDATE gift_cards
|
UPDATE gift_cards
|
||||||
SET total_funds_added = total_funds_added + $1,
|
SET total_funds_added = total_funds_added + $1,
|
||||||
amount_remaining = amount_remaining + $1
|
amount_remaining = amount_remaining + $1,
|
||||||
|
last_used_at = NOW()
|
||||||
WHERE id = $2
|
WHERE id = $2
|
||||||
`, req.Amount, cardID)
|
`, req.Amount, cardID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -132,6 +177,24 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
transactionType := "topup"
|
||||||
|
var notes *string
|
||||||
|
if isInventory && previousTotal == 0 {
|
||||||
|
transactionType = "purchase"
|
||||||
|
n := "first top-up on inventory card"
|
||||||
|
notes = &n
|
||||||
|
}
|
||||||
|
_, 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, 'till_sale', NULL, $4, $5)
|
||||||
|
`, cardID, transactionType, req.Amount, req.UserID, notes)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create gift_card_transaction: %v", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
giftCardID = cardID
|
giftCardID = cardID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +205,8 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
UPDATE gift_cards
|
UPDATE gift_cards
|
||||||
SET amount_remaining = 0,
|
SET amount_remaining = 0,
|
||||||
redeemed_at = NOW(),
|
redeemed_at = NOW(),
|
||||||
redeemed_by = $1
|
redeemed_by = $1,
|
||||||
|
last_used_at = NOW()
|
||||||
WHERE id = $2
|
WHERE id = $2
|
||||||
`, *req.RedeemToUserID, giftCardID)
|
`, *req.RedeemToUserID, giftCardID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -280,6 +344,12 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
squarePaymentID = &paymentResult.SquarePayID
|
squarePaymentID = &paymentResult.SquarePayID
|
||||||
saleStatus = "completed"
|
saleStatus = "completed"
|
||||||
|
case "on_the_house":
|
||||||
|
saleStatus = "completed"
|
||||||
|
dbPaymentMethod = "on_the_house"
|
||||||
|
if req.IdempotencyKey == "" {
|
||||||
|
req.IdempotencyKey = "till-on-the-house-" + giftCardID + "-" + time.Now().Format("20060102150405.000000")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
desc := fmt.Sprintf("Gift Card %s (£%.2f)", req.Action, req.Amount)
|
desc := fmt.Sprintf("Gift Card %s (£%.2f)", req.Action, req.Amount)
|
||||||
@@ -313,6 +383,16 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
UPDATE gift_card_transactions SET reference_id = $1
|
||||||
|
WHERE gift_card_id = $2 AND reference_id IS NULL AND created_at > NOW() - INTERVAL '5 seconds'
|
||||||
|
`, tillSaleID, giftCardID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to update gift_card_transactions reference: %v", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
log.Printf("Failed to commit till sale transaction: %v", err)
|
log.Printf("Failed to commit till sale transaction: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
//go:build test && dev
|
||||||
|
// +build test,dev
|
||||||
|
|
||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"crussell/db"
|
||||||
|
"crussell/mw"
|
||||||
|
"crussell/testutils/fixtures"
|
||||||
|
"crussell/testutils/jwt"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateTillSale_OnTheHouse(t *testing.T) {
|
||||||
|
resetTestData(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||||
|
|
||||||
|
reqBody := TillSaleRequest{
|
||||||
|
ItemType: "gift_card",
|
||||||
|
Action: "create",
|
||||||
|
Amount: 50.00,
|
||||||
|
PaymentMethod: "on_the_house",
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, _ := json.Marshal(reqBody)
|
||||||
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(mw.RequireAuth)
|
||||||
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp TillSaleResponse
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Status != "completed" {
|
||||||
|
t.Errorf("expected status 'completed', got '%s'", resp.Status)
|
||||||
|
}
|
||||||
|
if resp.PaymentMethod != "on_the_house" {
|
||||||
|
t.Errorf("expected payment method 'on_the_house', got '%s'", resp.PaymentMethod)
|
||||||
|
}
|
||||||
|
if resp.TotalAmount != 50.00 {
|
||||||
|
t.Errorf("expected total amount 50.00, got %.2f", resp.TotalAmount)
|
||||||
|
}
|
||||||
|
if resp.ItemType != "gift_card" {
|
||||||
|
t.Errorf("expected item type 'gift_card', got '%s'", resp.ItemType)
|
||||||
|
}
|
||||||
|
if resp.ItemID == nil || *resp.ItemID == "" {
|
||||||
|
t.Error("expected item_id to be set (gift card ID)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify till_sale was created in DB
|
||||||
|
var saleCount int
|
||||||
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales WHERE id = $1", resp.ID).Scan(&saleCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to query till_sales: %v", err)
|
||||||
|
}
|
||||||
|
if saleCount != 1 {
|
||||||
|
t.Errorf("expected 1 till_sale, got %d", saleCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify gift card was created
|
||||||
|
var gcCount int
|
||||||
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE id = $1", *resp.ItemID).Scan(&gcCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to query gift_cards: %v", err)
|
||||||
|
}
|
||||||
|
if gcCount != 1 {
|
||||||
|
t.Errorf("expected 1 gift_card, got %d", gcCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTillSale_Idempotency(t *testing.T) {
|
||||||
|
resetTestData(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||||
|
|
||||||
|
idempotencyKey := "test-idempotency-key-001"
|
||||||
|
|
||||||
|
reqBody := TillSaleRequest{
|
||||||
|
ItemType: "gift_card",
|
||||||
|
Action: "create",
|
||||||
|
Amount: 25.00,
|
||||||
|
PaymentMethod: "on_the_house",
|
||||||
|
IdempotencyKey: idempotencyKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, _ := json.Marshal(reqBody)
|
||||||
|
|
||||||
|
// First request
|
||||||
|
req1 := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
||||||
|
req1.Header.Set("Authorization", "Bearer "+adminToken)
|
||||||
|
req1.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w1 := httptest.NewRecorder()
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(mw.RequireAuth)
|
||||||
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||||
|
r.ServeHTTP(w1, req1)
|
||||||
|
|
||||||
|
if w1.Code != http.StatusCreated {
|
||||||
|
t.Errorf("first request: expected status 201, got %d. body: %s", w1.Code, w1.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp1 TillSaleResponse
|
||||||
|
if err := json.NewDecoder(w1.Body).Decode(&resp1); err != nil {
|
||||||
|
t.Fatalf("failed to decode first response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp1.ID == "" {
|
||||||
|
t.Fatal("expected first till_sale ID to be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second request with same idempotency key
|
||||||
|
req2 := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
||||||
|
req2.Header.Set("Authorization", "Bearer "+adminToken)
|
||||||
|
req2.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
r2 := chi.NewRouter()
|
||||||
|
r2.Use(mw.RequireAuth)
|
||||||
|
r2.Post("/api/admin/till/sale", CreateTillSale)
|
||||||
|
r2.ServeHTTP(w2, req2)
|
||||||
|
|
||||||
|
// Idempotent response returns 200 (the handler does not set WriteHeader in the idempotency path)
|
||||||
|
if w2.Code != http.StatusOK && w2.Code != http.StatusCreated {
|
||||||
|
t.Errorf("second request: expected status 200 or 201, got %d. body: %s", w2.Code, w2.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp2 TillSaleResponse
|
||||||
|
if err := json.NewDecoder(w2.Body).Decode(&resp2); err != nil {
|
||||||
|
t.Fatalf("failed to decode second response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp1.ID != resp2.ID {
|
||||||
|
t.Errorf("expected same till_sale ID for idempotent request, got %s and %s", resp1.ID, resp2.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify only one till_sale exists
|
||||||
|
var saleCount int
|
||||||
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales").Scan(&saleCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to query till_sales: %v", err)
|
||||||
|
}
|
||||||
|
if saleCount != 1 {
|
||||||
|
t.Errorf("expected 1 till_sale (idempotent), got %d", saleCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTillSale_CreatesGiftCardTransaction(t *testing.T) {
|
||||||
|
resetTestData(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||||
|
|
||||||
|
reqBody := TillSaleRequest{
|
||||||
|
ItemType: "gift_card",
|
||||||
|
Action: "create",
|
||||||
|
Amount: 50.00,
|
||||||
|
PaymentMethod: "on_the_house",
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, _ := json.Marshal(reqBody)
|
||||||
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(mw.RequireAuth)
|
||||||
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp TillSaleResponse
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.ItemID == nil || *resp.ItemID == "" {
|
||||||
|
t.Fatal("expected item_id to be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify gift_card_transactions was created
|
||||||
|
var txCount int
|
||||||
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'purchase'", *resp.ItemID).Scan(&txCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to query gift_card_transactions: %v", err)
|
||||||
|
}
|
||||||
|
if txCount != 1 {
|
||||||
|
t.Errorf("expected 1 gift_card_transaction with type 'purchase', got %d", txCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the transaction has reference_type = 'till_sale' and reference_id is set
|
||||||
|
var refCount int
|
||||||
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id IS NOT NULL", *resp.ItemID).Scan(&refCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to query gift_card_transactions with reference: %v", err)
|
||||||
|
}
|
||||||
|
if refCount != 1 {
|
||||||
|
t.Errorf("expected 1 gift_card_transaction with reference_type 'till_sale' and reference_id set, got %d", refCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTillSale_InvalidPaymentMethod(t *testing.T) {
|
||||||
|
resetTestData(t)
|
||||||
|
|
||||||
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||||
|
|
||||||
|
reqBody := TillSaleRequest{
|
||||||
|
ItemType: "gift_card",
|
||||||
|
Action: "create",
|
||||||
|
Amount: 50.00,
|
||||||
|
PaymentMethod: "invalid_method",
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, _ := json.Marshal(reqBody)
|
||||||
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(mw.RequireAuth)
|
||||||
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTillSale_TopupOnRedeemedCard(t *testing.T) {
|
||||||
|
resetTestData(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||||
|
|
||||||
|
// Create a test user to act as the redeemer
|
||||||
|
redeemerID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create redeemer user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert a gift card that is already redeemed
|
||||||
|
var cardID string
|
||||||
|
err = db.DB.QueryRow(ctx, `
|
||||||
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_by, redeemed_at)
|
||||||
|
VALUES (50.00, 0.00, $1, $2, NOW())
|
||||||
|
RETURNING id
|
||||||
|
`, adminID, redeemerID).Scan(&cardID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to insert redeemed gift card: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to topup the redeemed card
|
||||||
|
reqBody := TillSaleRequest{
|
||||||
|
ItemType: "gift_card",
|
||||||
|
Action: "topup",
|
||||||
|
Amount: 25.00,
|
||||||
|
PaymentMethod: "on_the_house",
|
||||||
|
GiftCardID: &cardID,
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, _ := json.Marshal(reqBody)
|
||||||
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(mw.RequireAuth)
|
||||||
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user