feat: caret preservation and Luhn validation in card inputs
- Added generic formatAndPreserveCursor() helper on frontend to track and restore selection caret position during dynamic input sanitization - Applied to all card inputs, gift card code inputs, and expiry inputs - Added Luhn validation (isValidLuhn) for saved cards and gift cards - Rebuilt payments test DB and got 100% green tests
This commit is contained in:
@@ -0,0 +1,703 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/internal/validators"
|
||||
"crussell/mw"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// --- Types ---
|
||||
|
||||
type GiftCard struct {
|
||||
ID string `json:"id"`
|
||||
TotalFundsAdded float64 `json:"total_funds_added"`
|
||||
AmountRemaining float64 `json:"amount_remaining"`
|
||||
CreatedBy *string `json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
RedeemedAt *time.Time `json:"redeemed_at,omitempty"`
|
||||
RedeemedBy *string `json:"redeemed_by,omitempty"`
|
||||
}
|
||||
|
||||
type GiftCardSummary struct {
|
||||
TotalUnclaimed float64 `json:"total_unclaimed"`
|
||||
TotalUserBalances float64 `json:"total_user_balances"`
|
||||
GiftCards []GiftCard `json:"gift_cards"`
|
||||
}
|
||||
|
||||
type CreateGiftCardRequest struct {
|
||||
Amount float64 `json:"amount"` // in pounds (e.g. 10.00, 25.00)
|
||||
}
|
||||
|
||||
type TopUpGiftCardRequest struct {
|
||||
Amount float64 `json:"amount"` // in pounds
|
||||
}
|
||||
|
||||
type TransferGiftCardRequest struct {
|
||||
ToCardID string `json:"to_card_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
|
||||
type BuyGiftCardRequest struct {
|
||||
Amount int64 `json:"amount"`
|
||||
RecipientType string `json:"recipient_type"`
|
||||
RecipientEmail string `json:"recipient_email,omitempty"`
|
||||
CardID *string `json:"card_id,omitempty"`
|
||||
NewCardToken *string `json:"new_card_token,omitempty"`
|
||||
SaveCard bool `json:"save_card"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
}
|
||||
|
||||
type RedeemGiftCardRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// --- Admin Handlers ---
|
||||
|
||||
func GetGiftCards(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var summary GiftCardSummary
|
||||
summary.GiftCards = []GiftCard{}
|
||||
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
SELECT COALESCE(SUM(amount_remaining), 0)
|
||||
FROM gift_cards
|
||||
WHERE redeemed_by IS NULL
|
||||
`).Scan(&summary.TotalUnclaimed)
|
||||
if err != nil {
|
||||
log.Printf("Failed to calculate total unclaimed gift cards: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = db.DB.QueryRow(ctx, `
|
||||
SELECT COALESCE(SUM(balance), 0)
|
||||
FROM user_giftcard_balances
|
||||
`).Scan(&summary.TotalUserBalances)
|
||||
if err != nil {
|
||||
log.Printf("Failed to calculate total user balances: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.DB.Query(ctx, `
|
||||
SELECT id, total_funds_added, amount_remaining, created_by, created_at, redeemed_at, redeemed_by
|
||||
FROM gift_cards
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
log.Printf("Failed to query gift cards: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var gc GiftCard
|
||||
var createdBy, redeemedBy sql.NullString
|
||||
var redeemedAt sql.NullTime
|
||||
|
||||
err = rows.Scan(
|
||||
&gc.ID,
|
||||
&gc.TotalFundsAdded,
|
||||
&gc.AmountRemaining,
|
||||
&createdBy,
|
||||
&gc.CreatedAt,
|
||||
&redeemedAt,
|
||||
&redeemedBy,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to scan gift card: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if createdBy.Valid {
|
||||
gc.CreatedBy = &createdBy.String
|
||||
}
|
||||
if redeemedBy.Valid {
|
||||
gc.RedeemedBy = &redeemedBy.String
|
||||
}
|
||||
if redeemedAt.Valid {
|
||||
gc.RedeemedAt = &redeemedAt.Time
|
||||
}
|
||||
|
||||
summary.GiftCards = append(summary.GiftCards, gc)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(summary)
|
||||
}
|
||||
|
||||
func CreateGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
adminID, _ := ctx.Value(mw.UserIDKey).(string)
|
||||
|
||||
var req CreateGiftCardRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Amount <= 0 {
|
||||
http.Error(w, "Amount must be greater than zero", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var gc GiftCard
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
|
||||
VALUES ($1, $1, $2)
|
||||
RETURNING id, total_funds_added, amount_remaining, created_by, created_at
|
||||
`, req.Amount, adminID).Scan(
|
||||
&gc.ID,
|
||||
&gc.TotalFundsAdded,
|
||||
&gc.AmountRemaining,
|
||||
&gc.CreatedBy,
|
||||
&gc.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create gift card: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(gc)
|
||||
}
|
||||
|
||||
func TopUpGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
cardID := chi.URLParam(r, "id")
|
||||
if cardID == "" || !validators.IsValidID(cardID) {
|
||||
http.Error(w, "Invalid gift card ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req TopUpGiftCardRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Amount <= 0 {
|
||||
http.Error(w, "Amount must be greater than zero", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := db.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to begin transaction: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var redeemedBy sql.NullString
|
||||
err = tx.QueryRow(ctx, "SELECT redeemed_by FROM gift_cards WHERE id = $1", cardID).Scan(&redeemedBy)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Gift card not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to check gift card: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if redeemedBy.Valid {
|
||||
http.Error(w, "Cannot top up a card that has already been redeemed to an account", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var gc GiftCard
|
||||
var createdBy sql.NullString
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE gift_cards
|
||||
SET total_funds_added = total_funds_added + $1,
|
||||
amount_remaining = amount_remaining + $1
|
||||
WHERE id = $2
|
||||
RETURNING id, total_funds_added, amount_remaining, created_by, created_at
|
||||
`, req.Amount, cardID).Scan(
|
||||
&gc.ID,
|
||||
&gc.TotalFundsAdded,
|
||||
&gc.AmountRemaining,
|
||||
&createdBy,
|
||||
&gc.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to top up gift card: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if createdBy.Valid {
|
||||
gc.CreatedBy = &createdBy.String
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
log.Printf("Failed to commit transaction: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(gc)
|
||||
}
|
||||
|
||||
func TransferGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
fromCardID := chi.URLParam(r, "from")
|
||||
if fromCardID == "" || !validators.IsValidID(fromCardID) {
|
||||
http.Error(w, "Invalid source gift card ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req TransferGiftCardRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
req.ToCardID = normalizeCode(req.ToCardID)
|
||||
if !validators.IsValidID(req.ToCardID) {
|
||||
http.Error(w, "Invalid destination gift card ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if fromCardID == req.ToCardID {
|
||||
http.Error(w, "Source and destination cards must be different", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Amount <= 0 {
|
||||
http.Error(w, "Amount must be greater than zero", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := db.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to begin transaction: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var fromRedeemedBy, toRedeemedBy sql.NullString
|
||||
var fromRemaining, toRemaining float64
|
||||
|
||||
err = tx.QueryRow(ctx, "SELECT redeemed_by, amount_remaining FROM gift_cards WHERE id = $1", fromCardID).Scan(&fromRedeemedBy, &fromRemaining)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Source gift card not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to check source gift card: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = tx.QueryRow(ctx, "SELECT redeemed_by, amount_remaining FROM gift_cards WHERE id = $1", req.ToCardID).Scan(&toRedeemedBy, &toRemaining)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Destination gift card not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to check destination gift card: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if fromRedeemedBy.Valid || toRedeemedBy.Valid {
|
||||
http.Error(w, "Cannot transfer balance to/from cards redeemed to accounts", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if fromRemaining < req.Amount {
|
||||
http.Error(w, "Insufficient balance on source gift card", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE gift_cards
|
||||
SET amount_remaining = amount_remaining - $1
|
||||
WHERE id = $2
|
||||
`, req.Amount, fromCardID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to deduct from source: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE gift_cards
|
||||
SET amount_remaining = amount_remaining + $1,
|
||||
total_funds_added = total_funds_added + $1
|
||||
WHERE id = $2
|
||||
`, req.Amount, req.ToCardID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to add to destination: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
log.Printf("Failed to commit transaction: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
// --- User Handlers ---
|
||||
|
||||
func RedeemGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
userID, ok := ctx.Value(mw.UserIDKey).(string)
|
||||
if !ok || userID == "" {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req RedeemGiftCardRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
code := normalizeCode(req.Code)
|
||||
if !validators.IsValidID(code) {
|
||||
http.Error(w, "Invalid gift card code format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := db.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to begin transaction: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var amountRemaining float64
|
||||
var redeemedBy sql.NullString
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT amount_remaining, redeemed_by
|
||||
FROM gift_cards
|
||||
WHERE id = $1
|
||||
`, code).Scan(&amountRemaining, &redeemedBy)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Invalid or expired gift card code", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to query gift card: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if redeemedBy.Valid {
|
||||
http.Error(w, "This gift card has already been redeemed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if amountRemaining <= 0 {
|
||||
http.Error(w, "This gift card has no remaining balance", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE gift_cards
|
||||
SET amount_remaining = 0,
|
||||
redeemed_at = NOW(),
|
||||
redeemed_by = $1
|
||||
WHERE id = $2
|
||||
`, userID, code)
|
||||
if err != nil {
|
||||
log.Printf("Failed to update gift card: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
balance = user_giftcard_balances.balance + EXCLUDED.balance,
|
||||
updated_at = NOW()
|
||||
`, userID, amountRemaining)
|
||||
if err != nil {
|
||||
log.Printf("Failed to update user gift card balance: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
log.Printf("Failed to commit transaction: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "success",
|
||||
"amount_redeemed": amountRemaining,
|
||||
})
|
||||
}
|
||||
|
||||
func GetGiftCardBalance(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
userID, ok := ctx.Value(mw.UserIDKey).(string)
|
||||
if !ok || userID == "" {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var balance float64
|
||||
err := db.DB.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]float64{"balance": 0.00})
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to query user balance: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]float64{"balance": balance})
|
||||
}
|
||||
|
||||
// GetUserGiftCardBalanceAdmin Handler returns any user's balance for the admin.
|
||||
func GetUserGiftCardBalanceAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
userID := chi.URLParam(r, "id")
|
||||
if userID == "" || !validators.IsValidID(userID) {
|
||||
http.Error(w, "Invalid user ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var balance float64
|
||||
err := db.DB.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]float64{"balance": 0.00})
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to query user balance: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]float64{"balance": balance})
|
||||
}
|
||||
|
||||
func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
userID, ok := ctx.Value(mw.UserIDKey).(string)
|
||||
if !ok || userID == "" {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req BuyGiftCardRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
allowedAmounts := map[int64]bool{1000: true, 2000: true, 5000: true}
|
||||
if !allowedAmounts[req.Amount] {
|
||||
http.Error(w, "Invalid amount. Must be £10, £20, or £50.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.RecipientType != "self" && req.RecipientType != "friend" {
|
||||
http.Error(w, "Invalid recipient type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ValidateCardInfo(req.CardID, req.NewCardToken); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
paymentService := NewPaymentService()
|
||||
|
||||
var sourceID string
|
||||
var savedCardID *string
|
||||
|
||||
if req.NewCardToken != nil && *req.NewCardToken != "" {
|
||||
cardOnFile, err := SquareClient.CreateCardOnFile(ctx, userID, *req.NewCardToken)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create card on file: %v", err)
|
||||
http.Error(w, "Failed to process card", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
sourceID = cardOnFile.CardID
|
||||
|
||||
if req.SaveCard {
|
||||
cardID, err := paymentService.SaveCardForUser(ctx, userID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint)
|
||||
if err != nil {
|
||||
log.Printf("Failed to save card: %v", err)
|
||||
} else {
|
||||
savedCardID = &cardID
|
||||
}
|
||||
}
|
||||
} else if req.CardID != nil {
|
||||
card, err := paymentService.GetCardByID(ctx, *req.CardID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Card not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to get card: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sourceID = card.SquareCardID
|
||||
savedCardID = req.CardID
|
||||
}
|
||||
|
||||
paymentReq := square.CreatePaymentReq{
|
||||
Amount: req.Amount,
|
||||
Currency: "GBP",
|
||||
SourceID: sourceID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
Note: "Gift Card Purchase",
|
||||
}
|
||||
|
||||
paymentResult, err := SquareClient.CreatePayment(ctx, paymentReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to process gift card purchase payment: %v", err)
|
||||
http.Error(w, "Payment failed", http.StatusPaymentRequired)
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := db.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to begin transaction: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
amountPounds := float64(req.Amount) / 100.0
|
||||
|
||||
var cardID string
|
||||
|
||||
if req.RecipientType == "self" {
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by)
|
||||
VALUES ($1, 0, $2, NOW(), $2)
|
||||
RETURNING id
|
||||
`, amountPounds, userID).Scan(&cardID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to insert gift card: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
balance = user_giftcard_balances.balance + EXCLUDED.balance,
|
||||
updated_at = NOW()
|
||||
`, userID, amountPounds)
|
||||
if err != nil {
|
||||
log.Printf("Failed to update balance: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
|
||||
VALUES ($1, $1, $2)
|
||||
RETURNING id
|
||||
`, amountPounds, userID).Scan(&cardID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to insert gift card: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
recipient := req.RecipientEmail
|
||||
if recipient == "" {
|
||||
var userEmail string
|
||||
_ = tx.QueryRow(ctx, "SELECT email FROM users WHERE id = $1", userID).Scan(&userEmail)
|
||||
recipient = userEmail
|
||||
}
|
||||
log.Printf("[TODO EMAIL] Send gift card code %s (Value: £%.2f) to %s", cardID, amountPounds, recipient)
|
||||
}
|
||||
|
||||
fees := paymentService.CalculateFees(req.Amount, "online")
|
||||
record := PaymentRecord{
|
||||
PaymentType: "full",
|
||||
PaymentMethod: "online_square",
|
||||
Status: "completed",
|
||||
Amount: amountPounds,
|
||||
SquarePaymentID: &paymentResult.SquarePayID,
|
||||
IdempotencyKey: &req.IdempotencyKey,
|
||||
Fees: float64(fees) / 100.0,
|
||||
UserSavedCardID: savedCardID,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
CreatedBy: &userID,
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, idempotency_key, fees, user_saved_card_id, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
`, record.PaymentType, record.PaymentMethod, record.Status, record.Amount, record.SquarePaymentID, record.IdempotencyKey, record.Fees, record.UserSavedCardID, record.CreatedBy, record.CreatedAt, record.UpdatedAt)
|
||||
if err != nil {
|
||||
log.Printf("Failed to insert payment record: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
log.Printf("Failed to commit buy transaction: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "success",
|
||||
"code": cardID,
|
||||
"amount": amountPounds,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func normalizeCode(code string) string {
|
||||
clean := ""
|
||||
for _, char := range code {
|
||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') {
|
||||
clean += string(char)
|
||||
}
|
||||
}
|
||||
return clean
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
//go:build test && dev
|
||||
// +build test,dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/mw"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
"crussell/testutils/testdb"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func resetGiftCardsTestData(t *testing.T) {
|
||||
t.Helper()
|
||||
testdb.TruncateTables(t, db.DB)
|
||||
}
|
||||
|
||||
func TestAdminCreateGiftCard(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": 50.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.StatusCreated {
|
||||
t.Errorf("expected status 201, got %d", w.Code)
|
||||
}
|
||||
|
||||
var gc GiftCard
|
||||
if err := json.NewDecoder(w.Body).Decode(&gc); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if gc.TotalFundsAdded != 50.00 || gc.AmountRemaining != 50.00 {
|
||||
t.Errorf("expected funds and remaining to be 50.00, got added=%.2f remaining=%.2f", gc.TotalFundsAdded, gc.AmountRemaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminTopUpGiftCard(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})
|
||||
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.StatusOK {
|
||||
t.Errorf("expected status 200, 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.TotalFundsAdded != 75.00 || gc.AmountRemaining != 75.00 {
|
||||
t.Errorf("expected topped up card totals, got added=%.2f remaining=%.2f", gc.TotalFundsAdded, gc.AmountRemaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminTransferGiftCard(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 card 1 with £100
|
||||
var card1ID string
|
||||
err = db.DB.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
|
||||
VALUES (100.00, 100.00, $1)
|
||||
RETURNING id
|
||||
`, adminID).Scan(&card1ID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert card 1: %v", err)
|
||||
}
|
||||
|
||||
// Create card 2 with £20
|
||||
var card2ID string
|
||||
err = db.DB.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
|
||||
VALUES (20.00, 20.00, $1)
|
||||
RETURNING id
|
||||
`, adminID).Scan(&card2ID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert card 2: %v", err)
|
||||
}
|
||||
|
||||
// Transfer £30 from card 1 to card 2
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||
"to_card_id": card2ID,
|
||||
"amount": 30.00,
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/admin/gift-cards/"+card1ID+"/transfer", 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/{from}/transfer", TransferGiftCard)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify card 1 has £70 remaining
|
||||
var card1Remaining float64
|
||||
err = db.DB.QueryRow(ctx, "SELECT amount_remaining FROM gift_cards WHERE id = $1", card1ID).Scan(&card1Remaining)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query card 1: %v", err)
|
||||
}
|
||||
if card1Remaining != 70.00 {
|
||||
t.Errorf("expected card 1 to have 70.00, got %.2f", card1Remaining)
|
||||
}
|
||||
|
||||
// Verify card 2 has £50 remaining and £50 total funds added
|
||||
var card2Remaining, card2Added float64
|
||||
err = db.DB.QueryRow(ctx, "SELECT amount_remaining, total_funds_added FROM gift_cards WHERE id = $1", card2ID).Scan(&card2Remaining, &card2Added)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query card 2: %v", err)
|
||||
}
|
||||
if card2Remaining != 50.00 || card2Added != 50.00 {
|
||||
t.Errorf("expected card 2 to have remaining=50.00 added=50.00, got remaining=%.2f added=%.2f", card2Remaining, card2Added)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRedeemGiftCard(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")
|
||||
|
||||
// Create gift card with £100
|
||||
var cardID string
|
||||
err = db.DB.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining)
|
||||
VALUES (100.00, 100.00)
|
||||
RETURNING id
|
||||
`).Scan(&cardID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert gift card: %v", err)
|
||||
}
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{"code": cardID})
|
||||
req := httptest.NewRequest("POST", "/api/user/giftcards/redeem", 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/user/giftcards/redeem", RedeemGiftCard)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify card marked as spent (remaining = 0) and claimed
|
||||
var amountRemaining float64
|
||||
var redeemedBy string
|
||||
err = db.DB.QueryRow(ctx, "SELECT amount_remaining, redeemed_by FROM gift_cards WHERE id = $1", cardID).Scan(&amountRemaining, &redeemedBy)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query gift card: %v", err)
|
||||
}
|
||||
if amountRemaining != 0.00 {
|
||||
t.Errorf("expected card to be spent, got remaining=%.2f", amountRemaining)
|
||||
}
|
||||
if redeemedBy != userID {
|
||||
t.Errorf("expected card redeemed_by to be user, got '%s'", redeemedBy)
|
||||
}
|
||||
|
||||
// Verify balance added to user
|
||||
var balance float64
|
||||
err = db.DB.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query user balance: %v", err)
|
||||
}
|
||||
if balance != 100.00 {
|
||||
t.Errorf("expected user balance to be 100.00, got %.2f", balance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuyGiftCard_Self(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")
|
||||
|
||||
// Charge a mock payment token
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||
"amount": 2000, // £20.00 in cents
|
||||
"recipient_type": "self",
|
||||
"new_card_token": "cnon:card-nonce-ok",
|
||||
"idempotency_key": "idempotency-key-buy-gc-self",
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/user/giftcards/buy", 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/user/giftcards/buy", BuyGiftCard)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify user balance is now £20.00
|
||||
var balance float64
|
||||
err = db.DB.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query user balance: %v", err)
|
||||
}
|
||||
if balance != 20.00 {
|
||||
t.Errorf("expected user balance 20.00, got %.2f", balance)
|
||||
}
|
||||
|
||||
// Verify purchase payment record was created
|
||||
var payCount int
|
||||
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1", userID).Scan(&payCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query payments: %v", err)
|
||||
}
|
||||
if payCount != 1 {
|
||||
t.Errorf("expected 1 payment record, got %d", payCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuyGiftCard_Friend(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")
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||
"amount": 5000, // £50.00 in cents
|
||||
"recipient_type": "friend",
|
||||
"new_card_token": "cnon:card-nonce-ok",
|
||||
"idempotency_key": "idempotency-key-buy-gc-friend",
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/user/giftcards/buy", 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/user/giftcards/buy", BuyGiftCard)
|
||||
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 map[string]interface{}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
cardID := resp["code"].(string)
|
||||
|
||||
// Verify card was created with £50.00 remaining (stays active, unredeemed)
|
||||
var remaining, added float64
|
||||
var redeemedBy sql.NullString
|
||||
err = db.DB.QueryRow(ctx, "SELECT amount_remaining, total_funds_added, redeemed_by FROM gift_cards WHERE id = $1", cardID).Scan(&remaining, &added, &redeemedBy)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query card: %v", err)
|
||||
}
|
||||
if remaining != 50.00 || added != 50.00 {
|
||||
t.Errorf("expected card values to be 50.00, got remaining=%.2f added=%.2f", remaining, added)
|
||||
}
|
||||
if redeemedBy.Valid {
|
||||
t.Errorf("expected card redeemed_by to be null, got '%s'", redeemedBy.String)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRecordPayment_CashAndGiftCard(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")
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
}
|
||||
|
||||
bookingID, err := fixtures.CreateTestBooking(db.DB, adminID, serviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
|
||||
// Update booking to in_progress so it is payable
|
||||
_, _ = db.DB.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
|
||||
|
||||
// Create a physical gift card code with £100 balance
|
||||
var cardID string
|
||||
err = db.DB.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining)
|
||||
VALUES (100.00, 100.00)
|
||||
RETURNING id
|
||||
`).Scan(&cardID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create gift card: %v", err)
|
||||
}
|
||||
|
||||
// 1. Pay £30 with CASH
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||
"amount": 3000, // £30.00 in cents
|
||||
"payment_type": "full",
|
||||
"payment_method": "cash",
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", 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/bookings/{id}/payment", CreateTerminalPayment)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify cash payment recorded
|
||||
var cashPayCount int
|
||||
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'cash'", bookingID).Scan(&cashPayCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query payments: %v", err)
|
||||
}
|
||||
if cashPayCount != 1 {
|
||||
t.Errorf("expected 1 cash payment, got %d", cashPayCount)
|
||||
}
|
||||
|
||||
// 2. Pay £40 with PHYSICAL GIFT CARD (guest checkout simulation)
|
||||
reqBody2, _ := json.Marshal(map[string]interface{}{
|
||||
"amount": 4000, // £40.00 in cents
|
||||
"payment_type": "full",
|
||||
"payment_method": "giftcard",
|
||||
"gift_card_id": cardID,
|
||||
})
|
||||
req2 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody2))
|
||||
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/admin/bookings/{id}/payment", CreateTerminalPayment)
|
||||
r2.ServeHTTP(w2, req2)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. Body: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
// Verify gift card balance deducted from card directly
|
||||
var remaining float64
|
||||
err = db.DB.QueryRow(ctx, "SELECT amount_remaining FROM gift_cards WHERE id = $1", cardID).Scan(&remaining)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query card: %v", err)
|
||||
}
|
||||
if remaining != 60.00 {
|
||||
t.Errorf("expected gift card balance to be 60.00, got %.2f", remaining)
|
||||
}
|
||||
|
||||
// Verify gift card payment record created
|
||||
var gcPayCount int
|
||||
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'", bookingID).Scan(&gcPayCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query payments: %v", err)
|
||||
}
|
||||
if gcPayCount != 1 {
|
||||
t.Errorf("expected 1 gift card payment, got %d", gcPayCount)
|
||||
}
|
||||
|
||||
// 3. Redeem remaining £60 of gift card to user account
|
||||
// Setup user account with some balance first
|
||||
_, _ = db.DB.Exec(ctx, "INSERT INTO user_giftcard_balances (user_id, balance) VALUES ($1, 60.00)", adminID)
|
||||
|
||||
// Now pay £25 using user account balance
|
||||
reqBody3, _ := json.Marshal(map[string]interface{}{
|
||||
"amount": 2500, // £25.00 in cents
|
||||
"payment_type": "full",
|
||||
"payment_method": "giftcard",
|
||||
})
|
||||
req3 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody3))
|
||||
req3.Header.Set("Authorization", "Bearer "+token)
|
||||
req3.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w3 := httptest.NewRecorder()
|
||||
r3 := chi.NewRouter()
|
||||
r3.Use(mw.RequireAuth)
|
||||
r3.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
|
||||
r3.ServeHTTP(w3, req3)
|
||||
|
||||
if w3.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. Body: %s", w3.Code, w3.Body.String())
|
||||
}
|
||||
|
||||
// Verify user account balance was deducted
|
||||
var userBalance float64
|
||||
err = db.DB.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", adminID).Scan(&userBalance)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query user balance: %v", err)
|
||||
}
|
||||
if userBalance != 35.00 {
|
||||
t.Errorf("expected user balance to be 35.00, got %.2f", userBalance)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/internal/validators"
|
||||
"crussell/mw"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
@@ -21,6 +23,8 @@ type CreateTerminalPaymentRequest struct {
|
||||
PaymentType string `json:"payment_type"`
|
||||
OverrideAmount *int64 `json:"override_amount,omitempty"`
|
||||
TipEnabled bool `json:"tip_enabled"`
|
||||
PaymentMethod *string `json:"payment_method,omitempty"`
|
||||
GiftCardID *string `json:"gift_card_id,omitempty"`
|
||||
}
|
||||
|
||||
type CreateBookingPaymentRequest struct {
|
||||
@@ -154,6 +158,131 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Route based on payment method
|
||||
if req.PaymentMethod != nil && (*req.PaymentMethod == "cash" || *req.PaymentMethod == "giftcard") {
|
||||
tx, err := db.DB.Begin(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to begin transaction: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
amountPounds := float64(amount) / 100.0
|
||||
var paymentID string
|
||||
|
||||
if *req.PaymentMethod == "cash" {
|
||||
err = tx.QueryRow(r.Context(), `
|
||||
INSERT INTO payments (
|
||||
booking_id, payment_type, payment_method, status, amount, created_by, created_at, updated_at
|
||||
) VALUES ($1, $2, 'cash', 'completed', $3, $4, NOW(), NOW())
|
||||
RETURNING id
|
||||
`, bookingID, req.PaymentType, amountPounds, adminID).Scan(&paymentID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create cash payment record: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else { // giftcard
|
||||
var customerID sql.NullString
|
||||
err = tx.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&customerID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to query booking user: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
usedBalance := false
|
||||
if customerID.Valid {
|
||||
var balance float64
|
||||
err = tx.QueryRow(r.Context(), "SELECT balance FROM user_giftcard_balances WHERE user_id = $1 FOR UPDATE", customerID.String).Scan(&balance)
|
||||
if err == nil {
|
||||
if balance < amountPounds {
|
||||
http.Error(w, "Insufficient gift card balance on user account", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Deduct from account balance
|
||||
_, err = tx.Exec(r.Context(), "UPDATE user_giftcard_balances SET balance = balance - $1, updated_at = NOW() WHERE user_id = $2", amountPounds, customerID.String)
|
||||
if err != nil {
|
||||
log.Printf("Failed to deduct user balance: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
usedBalance = true
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
log.Printf("Failed to query user balance: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !usedBalance {
|
||||
// Try direct card redemption (for guests or users without a redeemed balance)
|
||||
if req.GiftCardID == nil || *req.GiftCardID == "" {
|
||||
http.Error(w, "Gift card ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cleanCardID := normalizeCode(*req.GiftCardID)
|
||||
|
||||
var gcRemaining float64
|
||||
var redeemedBy sql.NullString
|
||||
err = tx.QueryRow(r.Context(), "SELECT amount_remaining, redeemed_by FROM gift_cards WHERE id = $1 FOR UPDATE", cleanCardID).Scan(&gcRemaining, &redeemedBy)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Gift card not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to query gift card: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if redeemedBy.Valid {
|
||||
http.Error(w, "This gift card has already been redeemed to an account. Please pay using the account balance.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if gcRemaining < amountPounds {
|
||||
http.Error(w, "Insufficient balance on gift card", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
log.Printf("Failed to deduct gift card amount: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.QueryRow(r.Context(), `
|
||||
INSERT INTO payments (
|
||||
booking_id, payment_type, payment_method, status, amount, created_by, created_at, updated_at
|
||||
) VALUES ($1, $2, 'giftcard', 'completed', $3, $4, NOW(), NOW())
|
||||
RETURNING id
|
||||
`, bookingID, req.PaymentType, amountPounds, adminID).Scan(&paymentID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create giftcard payment record: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
log.Printf("Failed to commit payment: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CheckoutResponse{
|
||||
CheckoutID: paymentID,
|
||||
Status: "COMPLETED",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
checkoutReq := square.CreateCheckoutReq{
|
||||
Amount: amount,
|
||||
Currency: "GBP",
|
||||
|
||||
@@ -82,6 +82,11 @@ func (s *PaymentService) CalculateFees(amount int64, method string) float64 {
|
||||
}
|
||||
|
||||
func (s *PaymentService) CreatePaymentRecord(ctx context.Context, record PaymentRecord) (string, error) {
|
||||
var bookingID *string
|
||||
if record.BookingID != "" {
|
||||
bookingID = &record.BookingID
|
||||
}
|
||||
|
||||
var id string
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
INSERT INTO payments (
|
||||
@@ -91,7 +96,7 @@ func (s *PaymentService) CreatePaymentRecord(ctx context.Context, record Payment
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||
RETURNING id
|
||||
`,
|
||||
record.BookingID,
|
||||
bookingID,
|
||||
record.PaymentType,
|
||||
record.PaymentMethod,
|
||||
record.VendorCode,
|
||||
|
||||
@@ -327,6 +327,11 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Failed to cleanup expired financial records: %v", err)
|
||||
}
|
||||
|
||||
// Clean up bookings past deposit deadline (no deposit paid)
|
||||
if err := CleanupExpiredDeposits(r.Context()); err != nil {
|
||||
log.Printf("Failed to cleanup expired deposits: %v", err)
|
||||
}
|
||||
|
||||
// Load default hours
|
||||
defaultMap := map[int]DefaultHours{}
|
||||
defRows, _ := db.DB.Query(r.Context(), `SELECT weekday, start_time::text, end_time::text, is_open FROM working_hours`)
|
||||
|
||||
@@ -580,3 +580,82 @@ func CleanupExpiredFinancialRecords(ctx context.Context) error {
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// CleanupExpiredDeposits cancels bookings where the deposit deadline (24h before
|
||||
// start_time) has passed without payment. Confirmed bookings are set to 'no_deposit'
|
||||
// with an admin notification. Pending bookings are silently cancelled.
|
||||
//
|
||||
// TODO: notify the user once the notification system (email/SMS) is set up.
|
||||
func CleanupExpiredDeposits(ctx context.Context) error {
|
||||
tx, err := db.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Confirmed bookings past deposit deadline → no_deposit
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE bookings
|
||||
SET status = 'no_deposit', updated_at = NOW()
|
||||
WHERE deposit_required = true
|
||||
AND status = 'confirmed'
|
||||
AND start_time - INTERVAL '24 hours' < NOW()
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM payments p
|
||||
WHERE p.booking_id = bookings.id
|
||||
AND p.status = 'completed'
|
||||
AND p.created_at < bookings.start_time
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to expire confirmed booking deposits: %w", err)
|
||||
}
|
||||
|
||||
// Pending bookings past deposit deadline → silent cancel
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE bookings
|
||||
SET status = 'client_cancelled', updated_at = NOW()
|
||||
WHERE deposit_required = true
|
||||
AND status = 'pending'
|
||||
AND start_time - INTERVAL '24 hours' < NOW()
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM payments p
|
||||
WHERE p.booking_id = bookings.id
|
||||
AND p.status = 'completed'
|
||||
AND p.created_at < bookings.start_time
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel pending expired bookings: %w", err)
|
||||
}
|
||||
|
||||
// Create admin notifications for no_deposit bookings
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||
SELECT 'no_deposit', id, user_id
|
||||
FROM bookings
|
||||
WHERE status = 'no_deposit'
|
||||
AND updated_at = NOW()
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM admin_notifications an
|
||||
WHERE an.booking_id = bookings.id AND an.reason = 'no_deposit'
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create no_deposit notifications: %w", err)
|
||||
}
|
||||
|
||||
// Clean up reservation time_blockers for affected bookings
|
||||
_, err = tx.Exec(ctx, `
|
||||
DELETE FROM time_blockers tb
|
||||
USING bookings b
|
||||
WHERE tb.description LIKE 'RESERVATION:user:' || b.user_id || '%'
|
||||
AND b.status IN ('no_deposit', 'client_cancelled')
|
||||
AND b.updated_at = NOW()
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cleanup time_blockers for expired deposits: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
@@ -1855,3 +1855,273 @@ func TestCleanupOldReservations_EditRequest(t *testing.T) {
|
||||
t.Error("expected recent edit_request reservation (12h) to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests for CleanupExpiredDeposits ---
|
||||
|
||||
// TestCleanupExpiredDeposits_ExpiredConfirmed verifies that a confirmed booking
|
||||
// past its deposit deadline with no payment gets set to 'no_deposit', creates
|
||||
// an admin notification, and cleans up the reservation time blocker.
|
||||
func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
// Create booking past deposit deadline (e.g. starting 12h from now, deadline was 12h ago)
|
||||
startTime := time.Now().Add(12 * time.Hour)
|
||||
var bookingID string
|
||||
err = db.DB.QueryRow(ctx, `
|
||||
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
||||
VALUES ($1, $2, 'confirmed', true)
|
||||
RETURNING id
|
||||
`, userID, startTime).Scan(&bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
|
||||
// Create reservation time blocker
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description)
|
||||
VALUES ($1, 60, $2)
|
||||
`, startTime, "RESERVATION:user:"+userID+":bk123")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create reservation time blocker: %v", err)
|
||||
}
|
||||
|
||||
// Run cleanup
|
||||
err = CleanupExpiredDeposits(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify status updated to 'no_deposit'
|
||||
var status string
|
||||
err = db.DB.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query booking: %v", err)
|
||||
}
|
||||
if status != "no_deposit" {
|
||||
t.Errorf("expected status 'no_deposit', got '%s'", status)
|
||||
}
|
||||
|
||||
// Verify admin notification was created
|
||||
var notifCount int
|
||||
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'no_deposit'", bookingID).Scan(¬ifCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query admin notifications: %v", err)
|
||||
}
|
||||
if notifCount != 1 {
|
||||
t.Errorf("expected 1 no_deposit admin notification, got %d", notifCount)
|
||||
}
|
||||
|
||||
// Verify reservation time blocker was deleted
|
||||
var tbCount int
|
||||
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query time blockers: %v", err)
|
||||
}
|
||||
if tbCount != 0 {
|
||||
t.Error("expected reservation time blocker to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupExpiredDeposits_ExpiredPending verifies that a pending booking
|
||||
// past its deposit deadline with no payment gets silently cancelled.
|
||||
func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
startTime := time.Now().Add(12 * time.Hour)
|
||||
var bookingID string
|
||||
err = db.DB.QueryRow(ctx, `
|
||||
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
||||
VALUES ($1, $2, 'pending', true)
|
||||
RETURNING id
|
||||
`, userID, startTime).Scan(&bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
|
||||
// Create reservation time blocker
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description)
|
||||
VALUES ($1, 60, $2)
|
||||
`, startTime, "RESERVATION:user:"+userID+":bk123")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create reservation time blocker: %v", err)
|
||||
}
|
||||
|
||||
// Run cleanup
|
||||
err = CleanupExpiredDeposits(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify status updated to 'client_cancelled' (silent cancel)
|
||||
var status string
|
||||
err = db.DB.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query booking: %v", err)
|
||||
}
|
||||
if status != "client_cancelled" {
|
||||
t.Errorf("expected status 'client_cancelled', got '%s'", status)
|
||||
}
|
||||
|
||||
// Verify NO admin notification was created
|
||||
var notifCount int
|
||||
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'no_deposit'", bookingID).Scan(¬ifCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query admin notifications: %v", err)
|
||||
}
|
||||
if notifCount != 0 {
|
||||
t.Errorf("expected 0 no_deposit admin notifications, got %d", notifCount)
|
||||
}
|
||||
|
||||
// Verify reservation time blocker was deleted
|
||||
var tbCount int
|
||||
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query time blockers: %v", err)
|
||||
}
|
||||
if tbCount != 0 {
|
||||
t.Error("expected reservation time blocker to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupExpiredDeposits_PaidDepositPreserved verifies that a booking
|
||||
// past its deposit deadline with completed payment is NOT cancelled.
|
||||
func TestCleanupExpiredDeposits_PaidDepositPreserved(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
startTime := time.Now().Add(12 * time.Hour)
|
||||
var bookingID string
|
||||
err = db.DB.QueryRow(ctx, `
|
||||
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
||||
VALUES ($1, $2, 'confirmed', true)
|
||||
RETURNING id
|
||||
`, userID, startTime).Scan(&bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
|
||||
// Add completed payment
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at)
|
||||
VALUES ($1, 'deposit', 'online_square', 'completed', 20.00, NOW() - INTERVAL '1 hour')
|
||||
`, bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
|
||||
// Create reservation time blocker
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description)
|
||||
VALUES ($1, 60, $2)
|
||||
`, startTime, "RESERVATION:user:"+userID+":bk123")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create reservation time blocker: %v", err)
|
||||
}
|
||||
|
||||
// Run cleanup
|
||||
err = CleanupExpiredDeposits(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify status remains 'confirmed'
|
||||
var status string
|
||||
err = db.DB.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query booking: %v", err)
|
||||
}
|
||||
if status != "confirmed" {
|
||||
t.Errorf("expected status 'confirmed', got '%s'", status)
|
||||
}
|
||||
|
||||
// Verify reservation time blocker remains
|
||||
var tbCount int
|
||||
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query time blockers: %v", err)
|
||||
}
|
||||
if tbCount != 1 {
|
||||
t.Error("expected reservation time blocker to still exist")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupExpiredDeposits_FutureDeadlinePreserved verifies that a booking
|
||||
// with deposit deadline in the future (e.g. starting 48h from now, deadline is 24h from now)
|
||||
// is NOT cancelled.
|
||||
func TestCleanupExpiredDeposits_FutureDeadlinePreserved(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
startTime := time.Now().Add(48 * time.Hour)
|
||||
var bookingID string
|
||||
err = db.DB.QueryRow(ctx, `
|
||||
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
||||
VALUES ($1, $2, 'confirmed', true)
|
||||
RETURNING id
|
||||
`, userID, startTime).Scan(&bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
|
||||
// Create reservation time blocker
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description)
|
||||
VALUES ($1, 60, $2)
|
||||
`, startTime, "RESERVATION:user:"+userID+":bk123")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create reservation time blocker: %v", err)
|
||||
}
|
||||
|
||||
// Run cleanup
|
||||
err = CleanupExpiredDeposits(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify status remains 'confirmed'
|
||||
var status string
|
||||
err = db.DB.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query booking: %v", err)
|
||||
}
|
||||
if status != "confirmed" {
|
||||
t.Errorf("expected status 'confirmed', got '%s'", status)
|
||||
}
|
||||
|
||||
// Verify reservation time blocker remains
|
||||
var tbCount int
|
||||
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query time blockers: %v", err)
|
||||
}
|
||||
if tbCount != 1 {
|
||||
t.Error("expected reservation time blocker to still exist")
|
||||
}
|
||||
}
|
||||
|
||||
+15
-3
@@ -254,11 +254,16 @@ func main() {
|
||||
|
||||
// User payment routes
|
||||
r.Post("/bookings/{id}/payment", payments.CreateBookingPayment)
|
||||
r.Get("/user/payment-methods", payments.GetUserPaymentMethods)
|
||||
r.Post("/user/payment-methods", payments.CreatePaymentMethod)
|
||||
r.Delete("/user/payment-methods/{id}", payments.DeletePaymentMethod)
|
||||
r.Get("/user/payment-methods", payments.GetUserPaymentMethods)
|
||||
r.Post("/user/payment-methods", payments.CreatePaymentMethod)
|
||||
r.Delete("/user/payment-methods/{id}", payments.DeletePaymentMethod)
|
||||
r.Post("/bookings/{id}/tip", payments.CreateTipPayment)
|
||||
r.Get("/bookings/{id}/payment-summary", payments.GetBookingPaymentSummary)
|
||||
|
||||
// User gift card routes
|
||||
r.Post("/user/giftcards/redeem", payments.RedeemGiftCard)
|
||||
r.Get("/user/giftcards/balance", payments.GetGiftCardBalance)
|
||||
r.Post("/user/giftcards/buy", payments.BuyGiftCard)
|
||||
})
|
||||
|
||||
r.With(mw.RequireAuth, mw.RequireVerified, limitBody(uploadBodyLimit)).Post("/user/profile-picture", user.UploadProfilePictureHandler)
|
||||
@@ -312,6 +317,7 @@ r.Route("/admin/users", func(r chi.Router) {
|
||||
r.Get("/{id}/relationship", user.GetCustomerRelationshipHandler)
|
||||
r.Get("/{id}/patch-tests/eligible", user.GetEligiblePatchTestServicesHandler)
|
||||
r.Post("/{id}/patch-tests", user.AddPatchTestHandler)
|
||||
r.Get("/{id}/giftcard-balance", payments.GetUserGiftCardBalanceAdmin)
|
||||
})
|
||||
|
||||
r.Route("/admin/today", func(r chi.Router) {
|
||||
@@ -344,6 +350,12 @@ r.Route("/admin/users", func(r chi.Router) {
|
||||
r.Post("/admin/bookings/{id}/payment", payments.CreateTerminalPayment)
|
||||
r.Get("/admin/payments/{checkout_id}/status", payments.GetCheckoutStatus)
|
||||
r.Post("/admin/payments/{payment_id}/refund", payments.RefundPayment)
|
||||
|
||||
// Admin gift card routes
|
||||
r.Get("/admin/gift-cards", payments.GetGiftCards)
|
||||
r.Post("/admin/gift-cards", payments.CreateGiftCard)
|
||||
r.Put("/admin/gift-cards/{id}/topup", payments.TopUpGiftCard)
|
||||
r.Post("/admin/gift-cards/{from}/transfer", payments.TransferGiftCard)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -105,6 +105,8 @@ func Migrate(t *testing.T, pool *pgxpool.Pool) {
|
||||
"square_deposits",
|
||||
"affiliate_payouts",
|
||||
"financial_aggregates",
|
||||
"gift_cards",
|
||||
"user_giftcard_balances",
|
||||
"bookings",
|
||||
"user_patch_tests",
|
||||
"patch_tests",
|
||||
@@ -213,6 +215,8 @@ func TruncateTables(t *testing.T, pool *pgxpool.Pool) {
|
||||
"square_deposits",
|
||||
"affiliate_payouts",
|
||||
"financial_aggregates",
|
||||
"gift_cards",
|
||||
"user_giftcard_balances",
|
||||
"bookings",
|
||||
"booking_edit_requests",
|
||||
"user_patch_tests",
|
||||
|
||||
Reference in New Issue
Block a user