diff --git a/backend/handlers/payments/giftcards.go b/backend/handlers/payments/giftcards.go new file mode 100644 index 0000000..18dda40 --- /dev/null +++ b/backend/handlers/payments/giftcards.go @@ -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 +} diff --git a/backend/handlers/payments/giftcards_test.go b/backend/handlers/payments/giftcards_test.go new file mode 100644 index 0000000..3f4cfcd --- /dev/null +++ b/backend/handlers/payments/giftcards_test.go @@ -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) + } +} diff --git a/backend/handlers/payments/handlers.go b/backend/handlers/payments/handlers.go index 349c945..7ccfe2c 100644 --- a/backend/handlers/payments/handlers.go +++ b/backend/handlers/payments/handlers.go @@ -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", diff --git a/backend/handlers/payments/service.go b/backend/handlers/payments/service.go index 41b83a1..c2fa222 100644 --- a/backend/handlers/payments/service.go +++ b/backend/handlers/payments/service.go @@ -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, diff --git a/backend/handlers/scheduling/default-hours.go b/backend/handlers/scheduling/default-hours.go index 741931b..711d712 100644 --- a/backend/handlers/scheduling/default-hours.go +++ b/backend/handlers/scheduling/default-hours.go @@ -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`) diff --git a/backend/handlers/scheduling/time-blockers.go b/backend/handlers/scheduling/time-blockers.go index 79b829f..d7c8ae0 100644 --- a/backend/handlers/scheduling/time-blockers.go +++ b/backend/handlers/scheduling/time-blockers.go @@ -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) +} diff --git a/backend/handlers/scheduling/time_blockers_test.go b/backend/handlers/scheduling/time_blockers_test.go index 821f1bd..a59a5da 100644 --- a/backend/handlers/scheduling/time_blockers_test.go +++ b/backend/handlers/scheduling/time_blockers_test.go @@ -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") + } +} diff --git a/backend/main.go b/backend/main.go index a0f158a..0a11088 100644 --- a/backend/main.go +++ b/backend/main.go @@ -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) }) }) diff --git a/backend/testutils/testdb/testdb.go b/backend/testutils/testdb/testdb.go index 597757d..329d9fa 100644 --- a/backend/testutils/testdb/testdb.go +++ b/backend/testutils/testdb/testdb.go @@ -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", diff --git a/frontend/src/lib/components/admin/GiftCardsManagement.svelte b/frontend/src/lib/components/admin/GiftCardsManagement.svelte new file mode 100644 index 0000000..8f291b1 --- /dev/null +++ b/frontend/src/lib/components/admin/GiftCardsManagement.svelte @@ -0,0 +1,561 @@ + + + + +
+
+ Gift Card Management + + Create, top-up, and track gift cards. Unclaimed cards can be topped up or transferred. + +
+ +
+
+ + +
+
+
Total Unclaimed
+
+ {loading ? '...' : formatCurrency(summary.total_unclaimed)} +
+
+
+
User Account Balances
+
+ {loading ? '...' : formatCurrency(summary.total_user_balances)} +
+
+
+
Combined Liability
+
+ {loading ? '...' : formatCurrency(summary.total_unclaimed + summary.total_user_balances)} +
+
+
+ + + + +
+ {#if loading} + {#each Array(2) as _, i (i)} +
+ + + +
+ {/each} + {:else if summary.gift_cards.length === 0} +
+ No gift cards generated yet. +
+ {:else} + {#each summary.gift_cards as gc (gc.id)} +
+
+ {formatCardCode(gc.id)} + {#if gc.redeemed_by} + + Claimed + + {:else if gc.amount_remaining === 0} + + Spent + + {:else} + + Active + + {/if} +
+
+
+ Total Added: + {formatCurrency(gc.total_funds_added)} +
+
+ Remaining: + {formatCurrency(gc.amount_remaining)} +
+
+ Created: + {formatDate(gc.created_at)} +
+
+
+ {#if !gc.redeemed_by && gc.amount_remaining > 0} + + + {/if} +
+
+ {/each} + {/if} +
+
+
+ + + + + + Generate Gift Card + Generate a new gift card code with a starting balance. + + +
+
+ + + {#if generateError} + {generateError} + {/if} +
+
+ + + + + +
+
+ + + + + + Top Up Gift Card + Add additional funds to active, unclaimed gift card {formatCardCode(selectedCardId ?? '')}. + + +
+
+ + + {#if topUpError} + {topUpError} + {/if} +
+
+ + + + + +
+
+ + + + + + Transfer Balance + Transfer funds from {formatCardCode(selectedCardId ?? '')} directly to another card. + + +
+
+ + + {#if transferCodeError} + {transferCodeError} + {/if} +
+
+ + + {#if transferAmountError} + {transferAmountError} + {/if} +
+
+ + + + + +
+
\ No newline at end of file diff --git a/frontend/src/lib/components/payments/PaymentModal.svelte b/frontend/src/lib/components/payments/PaymentModal.svelte index 5ec6a85..446ab7f 100644 --- a/frontend/src/lib/components/payments/PaymentModal.svelte +++ b/frontend/src/lib/components/payments/PaymentModal.svelte @@ -43,6 +43,32 @@ let paymentResult = $state(null); let error = $state(null); + let customerBalance = $state(0); + let loadingCustomerBalance = $state(false); + let giftCardPaymentAmount = $state(''); + + async function fetchCustomerGiftCardBalance() { + if (!booking.user_id) return; + loadingCustomerBalance = true; + try { + const res = await fetch(`/api/admin/users/${booking.user_id}/giftcard-balance`, { + headers: { Authorization: `Bearer ${authStore.currentToken}` } + }); + if (res.ok) { + const data = await res.json(); + customerBalance = data.balance; + giftCardPaymentAmount = Math.min(data.balance, totalDue).toFixed(2); + if (data.balance > 0) { + useAccountBalance = true; + } + } + } catch { + // ignore + } finally { + loadingCustomerBalance = false; + } + } + type ServiceOverride = { price: string; originalPrice: number; @@ -51,6 +77,7 @@ let serviceOverrides = $state>({}); $effect(() => { + fetchCustomerGiftCardBalance(); const services = booking.services ?? []; const overrides: Record = {}; for (const s of services) { @@ -355,42 +382,105 @@ } let giftCardId = $state(''); + let useAccountBalance = $state(false); + + function formatAndPreserveCursor( + input: HTMLInputElement, + formatter: (val: string) => string, + charRegex: RegExp = /\d/ + ): string { + const rawValue = input.value; + const oldSelectionStart = input.selectionStart || 0; + + let charsBeforeCursor = 0; + for (let i = 0; i < oldSelectionStart; i++) { + if (charRegex.test(rawValue[i])) { + charsBeforeCursor++; + } + } + + const formatted = formatter(rawValue); + input.value = formatted; + + let newSelectionStart = 0; + let charsFound = 0; + for (let i = 0; i < formatted.length; i++) { + if (charsFound === charsBeforeCursor) { + break; + } + if (charRegex.test(formatted[i])) { + charsFound++; + } + newSelectionStart++; + } + + requestAnimationFrame(() => { + input.setSelectionRange(newSelectionStart, newSelectionStart); + }); + + return formatted; + } function formatGiftCardId(value: string): string { - const digits = value.replace(/\D/g, '').substring(0, 12); - const groups = digits.match(/.{1,4}/g); - return groups ? groups.join(' ') : digits; + let raw = value.replace(/[^a-zA-Z0-9]/g, '').toLowerCase(); + if (raw.length > 12) raw = raw.slice(0, 12); + let formatted = ''; + if (raw.length > 0) formatted += raw.slice(0, 4); + if (raw.length > 4) formatted += '-' + raw.slice(4, 8); + if (raw.length > 8) formatted += '-' + raw.slice(8, 12); + return formatted.toUpperCase(); } function handleGiftCardInput(e: Event) { const input = e.target as HTMLInputElement; - giftCardId = formatGiftCardId(input.value); + const formatted = formatAndPreserveCursor(input, formatGiftCardId, /[a-zA-Z0-9]/); + giftCardId = formatted; } - let giftCardValid = $derived(giftCardId.replace(/\s/g, '').length === 12); + let giftCardValid = $derived( + useAccountBalance || giftCardId.replace(/-/g, '').length === 12 + ); async function handleGiftCardPayment() { if (!giftCardValid) { - toast.error('Please enter a valid 12-digit gift card ID'); + toast.error('Please enter a valid 12-character gift card code'); return; } + let payAmountCents = Math.round(totalDue * 100); + if (useAccountBalance) { + const parsedAmt = parseFloat(giftCardPaymentAmount); + if (isNaN(parsedAmt) || parsedAmt <= 0) { + toast.error('Please enter a valid payment amount'); + return; + } + if (parsedAmt > customerBalance) { + toast.error('Payment amount exceeds available balance'); + return; + } + payAmountCents = Math.round(parsedAmt * 100); + } + status = 'gift-confirming'; error = null; try { + const body: any = { + amount: payAmountCents, + payment_type: 'full', + payment_method: 'giftcard' + }; + if (!useAccountBalance) { + body.gift_card_id = giftCardId.replace(/-/g, ''); + } + const response = await fetch(`/api/admin/bookings/${booking.id}/payment`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }, - body: JSON.stringify({ - amount: 1000, - payment_type: 'full', - payment_method: 'giftcard', - gift_card_id: giftCardId.replace(/\s/g, '') - }) + body: JSON.stringify(body) }); if (!response.ok) { @@ -749,27 +839,70 @@ {formatCurrency(totalDue)} -
- - -

Enter the 12-digit ID printed on the gift card

-
+ {#if booking.user_id && customerBalance > 0} +
+ Source +
+ + +
+
+ {/if} + + {#if useAccountBalance} +
+ + giftCardPaymentAmount = (e.target as HTMLInputElement).value} + class="mt-1 font-mono text-lg" + /> +

+ Available balance: {formatCurrency(customerBalance)}. Maximum of total due or balance can be used. +

+
+ {:else} +
+ + +

Enter the 12-character code printed on the gift card

+
+ {/if}
+
+ + + + + + + + + + + + + Buy a Gift Card + + Purchase a gift card online for yourself or a friend. + + + {#if purchaseResultCode} +
+
+ + + + Purchase Successful! +
+ {#if buyRecipientType === 'self'} +

+ Your purchase of {formatCurrency(buyAmount)} has been automatically added to your account balance! +

+ {:else} +

+ Here is your gift card code: +

+
+ {formatCardCode(purchaseResultCode)} +
+

+ Please save this code! It has been emailed to the recipient. +

+ {/if} + +
+ {:else} + +
+ Select Value +
+ {#each [10, 20, 50] as amount} + + {/each} +
+
+ + +
+ Recipient +
+ + +
+
+ + {#if buyRecipientType === 'friend'} +
+ + +
+ {/if} + + +
+ Payment Method + {#if savedCards.length > 0} +
+ {#each savedCards as card (card.id)} + + {/each} + +
+ {/if} + + {#if buySelectedCard === ''} +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ {/if} +
+ + + {/if} +
+
+ + @@ -1413,8 +1924,7 @@ type="text" inputmode="numeric" value={newCardNumber} - oninput={(e) => - (newCardNumber = formatCardNumber((e.target as HTMLInputElement).value))} + oninput={handleCardNumberInput} placeholder="1234 5678 9012 3456" maxlength={19} class="mt-1" @@ -1430,10 +1940,7 @@ type="text" inputmode="numeric" value={newCardExpiry} - oninput={(e) => - (newCardExpiry = formatExpiryDate( - (e.target as HTMLInputElement).value - ))} + oninput={handleExpiryInput} placeholder="MM/YY" maxlength={5} class="mt-1" @@ -1448,10 +1955,7 @@ type="text" inputmode="numeric" value={newCardCVC} - oninput={(e) => - (newCardCVC = (e.target as HTMLInputElement).value - .replace(/\D/g, '') - .substring(0, 4))} + oninput={handleCvcInput} placeholder="123" maxlength={4} class="mt-1" @@ -1775,6 +2279,7 @@ onclick={(_) => { activeTab = 'cards'; fetchSavedCards(); + fetchGiftCardBalance(); }} > + diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index fbd34d0..702c74e 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -387,7 +387,7 @@ CREATE SEQUENCE invoice_number_seq CREATE TABLE payments ( id CHAR(12) PRIMARY KEY DEFAULT generate_payment_id(), - booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE RESTRICT, + booking_id CHAR(12) REFERENCES bookings(id) ON DELETE RESTRICT, payment_type payment_type NOT NULL, payment_method payment_method NOT NULL, vendor_code TEXT, @@ -1556,6 +1556,30 @@ CREATE TABLE affiliate_payouts ( CREATE INDEX idx_affiliate_payouts_affiliate ON affiliate_payouts(affiliate_id); +-- ======================================= +-- GIFT CARDS TABLE +-- ======================================= + +CREATE TABLE gift_cards ( + id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('gift_cards'), + total_funds_added NUMERIC(12,2) NOT NULL DEFAULT 0, + amount_remaining NUMERIC(12,2) NOT NULL DEFAULT 0, + created_by CHAR(12) REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + redeemed_at TIMESTAMPTZ, + redeemed_by CHAR(12) REFERENCES users(id) +); + +-- ======================================= +-- USER GIFTCARD BALANCES TABLE +-- ======================================= + +CREATE TABLE user_giftcard_balances ( + user_id CHAR(12) PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + balance NUMERIC(12,2) NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + -- ======================================= -- SQUARE DEPOSITS TABLE (Bank Reconciliation) -- =======================================