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:
2026-06-11 22:08:18 +01:00
co-authored by Sisyphus
parent 6756723c60
commit 30ded0bb95
7 changed files with 1479 additions and 69 deletions
+390 -46
View File
@@ -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, &currentTotalFunds)
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, &notes)
if err != nil {
log.Printf("Failed to scan expired balance: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if accountID.Valid {
b.AccountID = &accountID.String
}
if claimedAt.Valid {
b.ClaimedAt = &claimedAt.Time
}
if claimedByAdmin.Valid {
b.ClaimedByAdmin = &claimedByAdmin.String
}
if notes.Valid {
b.Notes = &notes.String
}
balances = append(balances, b)
}
if 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"})
}
+624 -1
View File
@@ -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)
}
}
+4 -4
View File
@@ -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)
+2 -2
View File
@@ -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)
} }
+40 -9
View File
@@ -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 {
+87 -7
View File
@@ -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)
+332
View File
@@ -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())
}
}