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
|
package payments
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crussell/db"
|
||||||
"crussell/internal/square"
|
"crussell/internal/square"
|
||||||
"crussell/internal/validators"
|
"crussell/internal/validators"
|
||||||
"crussell/mw"
|
"crussell/mw"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
@@ -21,6 +23,8 @@ type CreateTerminalPaymentRequest struct {
|
|||||||
PaymentType string `json:"payment_type"`
|
PaymentType string `json:"payment_type"`
|
||||||
OverrideAmount *int64 `json:"override_amount,omitempty"`
|
OverrideAmount *int64 `json:"override_amount,omitempty"`
|
||||||
TipEnabled bool `json:"tip_enabled"`
|
TipEnabled bool `json:"tip_enabled"`
|
||||||
|
PaymentMethod *string `json:"payment_method,omitempty"`
|
||||||
|
GiftCardID *string `json:"gift_card_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateBookingPaymentRequest struct {
|
type CreateBookingPaymentRequest struct {
|
||||||
@@ -154,6 +158,131 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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{
|
checkoutReq := square.CreateCheckoutReq{
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
Currency: "GBP",
|
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) {
|
func (s *PaymentService) CreatePaymentRecord(ctx context.Context, record PaymentRecord) (string, error) {
|
||||||
|
var bookingID *string
|
||||||
|
if record.BookingID != "" {
|
||||||
|
bookingID = &record.BookingID
|
||||||
|
}
|
||||||
|
|
||||||
var id string
|
var id string
|
||||||
err := db.DB.QueryRow(ctx, `
|
err := db.DB.QueryRow(ctx, `
|
||||||
INSERT INTO payments (
|
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)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
record.BookingID,
|
bookingID,
|
||||||
record.PaymentType,
|
record.PaymentType,
|
||||||
record.PaymentMethod,
|
record.PaymentMethod,
|
||||||
record.VendorCode,
|
record.VendorCode,
|
||||||
|
|||||||
@@ -327,6 +327,11 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Printf("Failed to cleanup expired financial records: %v", err)
|
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
|
// Load default hours
|
||||||
defaultMap := map[int]DefaultHours{}
|
defaultMap := map[int]DefaultHours{}
|
||||||
defRows, _ := db.DB.Query(r.Context(), `SELECT weekday, start_time::text, end_time::text, is_open FROM working_hours`)
|
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)
|
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")
|
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
|
// User payment routes
|
||||||
r.Post("/bookings/{id}/payment", payments.CreateBookingPayment)
|
r.Post("/bookings/{id}/payment", payments.CreateBookingPayment)
|
||||||
r.Get("/user/payment-methods", payments.GetUserPaymentMethods)
|
r.Get("/user/payment-methods", payments.GetUserPaymentMethods)
|
||||||
r.Post("/user/payment-methods", payments.CreatePaymentMethod)
|
r.Post("/user/payment-methods", payments.CreatePaymentMethod)
|
||||||
r.Delete("/user/payment-methods/{id}", payments.DeletePaymentMethod)
|
r.Delete("/user/payment-methods/{id}", payments.DeletePaymentMethod)
|
||||||
r.Post("/bookings/{id}/tip", payments.CreateTipPayment)
|
r.Post("/bookings/{id}/tip", payments.CreateTipPayment)
|
||||||
r.Get("/bookings/{id}/payment-summary", payments.GetBookingPaymentSummary)
|
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)
|
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}/relationship", user.GetCustomerRelationshipHandler)
|
||||||
r.Get("/{id}/patch-tests/eligible", user.GetEligiblePatchTestServicesHandler)
|
r.Get("/{id}/patch-tests/eligible", user.GetEligiblePatchTestServicesHandler)
|
||||||
r.Post("/{id}/patch-tests", user.AddPatchTestHandler)
|
r.Post("/{id}/patch-tests", user.AddPatchTestHandler)
|
||||||
|
r.Get("/{id}/giftcard-balance", payments.GetUserGiftCardBalanceAdmin)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/admin/today", func(r chi.Router) {
|
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.Post("/admin/bookings/{id}/payment", payments.CreateTerminalPayment)
|
||||||
r.Get("/admin/payments/{checkout_id}/status", payments.GetCheckoutStatus)
|
r.Get("/admin/payments/{checkout_id}/status", payments.GetCheckoutStatus)
|
||||||
r.Post("/admin/payments/{payment_id}/refund", payments.RefundPayment)
|
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",
|
"square_deposits",
|
||||||
"affiliate_payouts",
|
"affiliate_payouts",
|
||||||
"financial_aggregates",
|
"financial_aggregates",
|
||||||
|
"gift_cards",
|
||||||
|
"user_giftcard_balances",
|
||||||
"bookings",
|
"bookings",
|
||||||
"user_patch_tests",
|
"user_patch_tests",
|
||||||
"patch_tests",
|
"patch_tests",
|
||||||
@@ -213,6 +215,8 @@ func TruncateTables(t *testing.T, pool *pgxpool.Pool) {
|
|||||||
"square_deposits",
|
"square_deposits",
|
||||||
"affiliate_payouts",
|
"affiliate_payouts",
|
||||||
"financial_aggregates",
|
"financial_aggregates",
|
||||||
|
"gift_cards",
|
||||||
|
"user_giftcard_balances",
|
||||||
"bookings",
|
"bookings",
|
||||||
"booking_edit_requests",
|
"booking_edit_requests",
|
||||||
"user_patch_tests",
|
"user_patch_tests",
|
||||||
|
|||||||
@@ -0,0 +1,561 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import * as Modal from '$lib/components/ui/dialog';
|
||||||
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||||
|
|
||||||
|
interface GiftCard {
|
||||||
|
id: string;
|
||||||
|
total_funds_added: number;
|
||||||
|
amount_remaining: number;
|
||||||
|
created_by?: string;
|
||||||
|
created_at: string;
|
||||||
|
redeemed_at?: string;
|
||||||
|
redeemed_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GiftCardSummary {
|
||||||
|
total_unclaimed: number;
|
||||||
|
total_user_balances: number;
|
||||||
|
gift_cards: GiftCard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = $state<GiftCardSummary>({
|
||||||
|
total_unclaimed: 0,
|
||||||
|
total_user_balances: 0,
|
||||||
|
gift_cards: []
|
||||||
|
});
|
||||||
|
|
||||||
|
let loading = $state(true);
|
||||||
|
let showGenerateModal = $state(false);
|
||||||
|
let showTopUpModal = $state(false);
|
||||||
|
let showTransferModal = $state(false);
|
||||||
|
|
||||||
|
let creating = $state(false);
|
||||||
|
let toppingUp = $state(false);
|
||||||
|
let transferring = $state(false);
|
||||||
|
|
||||||
|
let selectedCardId = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Form inputs
|
||||||
|
let generateAmount = $state('');
|
||||||
|
let topUpAmount = $state('');
|
||||||
|
let transferAmount = $state('');
|
||||||
|
let transferToCode = $state('');
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
let generateError = $derived(
|
||||||
|
generateAmount && (isNaN(Number(generateAmount)) || Number(generateAmount) <= 0)
|
||||||
|
? 'Must be a valid positive number'
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
let topUpError = $derived(
|
||||||
|
topUpAmount && (isNaN(Number(topUpAmount)) || Number(topUpAmount) <= 0)
|
||||||
|
? 'Must be a valid positive number'
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
let transferAmountError = $derived(
|
||||||
|
transferAmount && (isNaN(Number(transferAmount)) || Number(transferAmount) <= 0)
|
||||||
|
? 'Must be a valid positive number'
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
let transferCodeError = $derived(
|
||||||
|
transferToCode && transferToCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12
|
||||||
|
? 'Code must be exactly 12 characters'
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
let isGenerateValid = $derived(generateAmount && !generateError);
|
||||||
|
let isTopUpValid = $derived(topUpAmount && !topUpError);
|
||||||
|
let isTransferValid = $derived(transferAmount && !transferAmountError && transferToCode && !transferCodeError);
|
||||||
|
|
||||||
|
async function fetchGiftCards() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/gift-cards', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
summary = await res.json();
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to fetch gift cards');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Network error fetching gift cards');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateGiftCard() {
|
||||||
|
if (!isGenerateValid) return;
|
||||||
|
creating = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/gift-cards', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ amount: Number(generateAmount) })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
toast.success(`Gift card ${formatCardCode(data.id)} generated successfully!`);
|
||||||
|
showGenerateModal = false;
|
||||||
|
generateAmount = '';
|
||||||
|
await fetchGiftCards();
|
||||||
|
} else {
|
||||||
|
const errText = await res.text();
|
||||||
|
toast.error(errText || 'Failed to generate gift card');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Network error generating gift card');
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function topUpCard() {
|
||||||
|
if (!isTopUpValid || !selectedCardId) return;
|
||||||
|
toppingUp = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/gift-cards/${selectedCardId}/topup`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ amount: Number(topUpAmount) })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('Gift card topped up successfully');
|
||||||
|
showTopUpModal = false;
|
||||||
|
topUpAmount = '';
|
||||||
|
await fetchGiftCards();
|
||||||
|
} else {
|
||||||
|
const errText = await res.text();
|
||||||
|
toast.error(errText || 'Failed to top up gift card');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Network error topping up gift card');
|
||||||
|
} finally {
|
||||||
|
toppingUp = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transferCard() {
|
||||||
|
if (!isTransferValid || !selectedCardId) return;
|
||||||
|
transferring = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/gift-cards/${selectedCardId}/transfer`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
to_card_id: transferToCode,
|
||||||
|
amount: Number(transferAmount)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('Balance transferred successfully');
|
||||||
|
showTransferModal = false;
|
||||||
|
transferAmount = '';
|
||||||
|
transferToCode = '';
|
||||||
|
await fetchGiftCards();
|
||||||
|
} else {
|
||||||
|
const errText = await res.text();
|
||||||
|
toast.error(errText || 'Failed to transfer balance');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Network error transferring balance');
|
||||||
|
} finally {
|
||||||
|
transferring = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCodeInput(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
let raw = target.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);
|
||||||
|
transferToCode = formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCardCode(id: string): string {
|
||||||
|
if (id.length !== 12) return id;
|
||||||
|
return `${id.slice(0, 4)}-${id.slice(4, 8)}-${id.slice(8, 12)}`.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-GB', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (authStore.currentToken) {
|
||||||
|
fetchGiftCards();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<Card.Title>Gift Card Management</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
Create, top-up, and track gift cards. Unclaimed cards can be topped up or transferred.
|
||||||
|
</Card.Description>
|
||||||
|
</div>
|
||||||
|
<Button onclick={() => showGenerateModal = true} class="bg-fuchsia-600 hover:bg-fuchsia-700 text-white">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="mr-2 h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
Generate Gift Card
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
|
||||||
|
<Card.Content class="space-y-6">
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||||
|
<div class="rounded-lg border border-fuchsia-100 bg-fuchsia-50 p-4">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wider text-fuchsia-600">Total Unclaimed</div>
|
||||||
|
<div class="mt-1 text-2xl font-bold text-fuchsia-950">
|
||||||
|
{loading ? '...' : formatCurrency(summary.total_unclaimed)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-pink-100 bg-pink-50 p-4">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wider text-pink-600">User Account Balances</div>
|
||||||
|
<div class="mt-1 text-2xl font-bold text-pink-950">
|
||||||
|
{loading ? '...' : formatCurrency(summary.total_user_balances)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-purple-100 bg-purple-50 p-4 sm:col-span-2 md:col-span-1">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wider text-purple-600">Combined Liability</div>
|
||||||
|
<div class="mt-1 text-2xl font-bold text-purple-950">
|
||||||
|
{loading ? '...' : formatCurrency(summary.total_unclaimed + summary.total_user_balances)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hidden w-full overflow-x-auto md:block">
|
||||||
|
<table class="w-full table-auto border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b text-left text-xs text-gray-500 uppercase tracking-wider">
|
||||||
|
<th class="py-3 font-medium">Card Code</th>
|
||||||
|
<th class="py-3 font-medium text-right">Total Added</th>
|
||||||
|
<th class="py-3 font-medium text-right">Remaining</th>
|
||||||
|
<th class="py-3 font-medium">Created On</th>
|
||||||
|
<th class="py-3 font-medium">Status</th>
|
||||||
|
<th class="py-3 text-center font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#if loading}
|
||||||
|
{#each Array(3) as _, i (i)}
|
||||||
|
<tr class="border-b">
|
||||||
|
<td class="py-3"><Skeleton class="h-4 w-32" /></td>
|
||||||
|
<td class="py-3 text-right"><Skeleton class="ml-auto h-4 w-16" /></td>
|
||||||
|
<td class="py-3 text-right"><Skeleton class="ml-auto h-4 w-16" /></td>
|
||||||
|
<td class="py-3"><Skeleton class="h-4 w-24" /></td>
|
||||||
|
<td class="py-3"><Skeleton class="h-4 w-20" /></td>
|
||||||
|
<td class="py-3 text-center"><Skeleton class="mx-auto h-4 w-24" /></td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{:else if summary.gift_cards.length === 0}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="py-8 text-center text-gray-500">
|
||||||
|
No gift cards generated yet. Click "Generate Gift Card" to create one.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
{#each summary.gift_cards as gc (gc.id)}
|
||||||
|
<tr class="border-b hover:bg-gray-50">
|
||||||
|
<td class="py-3 font-mono font-bold text-gray-900">{formatCardCode(gc.id)}</td>
|
||||||
|
<td class="py-3 text-right font-medium text-gray-600">{formatCurrency(gc.total_funds_added)}</td>
|
||||||
|
<td class="py-3 text-right font-semibold {gc.amount_remaining > 0 ? 'text-fuchsia-700' : 'text-gray-400'}">
|
||||||
|
{formatCurrency(gc.amount_remaining)}
|
||||||
|
</td>
|
||||||
|
<td class="py-3 text-gray-600">{formatDate(gc.created_at)}</td>
|
||||||
|
<td class="py-3">
|
||||||
|
{#if gc.redeemed_by}
|
||||||
|
<span class="inline-flex items-center rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700 border border-green-200">
|
||||||
|
Claimed
|
||||||
|
</span>
|
||||||
|
{:else if gc.amount_remaining === 0}
|
||||||
|
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 border border-gray-200">
|
||||||
|
Spent
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="inline-flex items-center rounded-full bg-fuchsia-50 px-2.5 py-0.5 text-xs font-medium text-fuchsia-700 border border-fuchsia-200">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="py-3 text-center">
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
{#if !gc.redeemed_by && gc.amount_remaining > 0}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => {
|
||||||
|
selectedCardId = gc.id;
|
||||||
|
showTopUpModal = true;
|
||||||
|
}}
|
||||||
|
class="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50"
|
||||||
|
>
|
||||||
|
Top Up
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => {
|
||||||
|
selectedCardId = gc.id;
|
||||||
|
showTransferModal = true;
|
||||||
|
}}
|
||||||
|
class="border-pink-200 text-pink-700 hover:bg-pink-50"
|
||||||
|
>
|
||||||
|
Transfer
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-gray-400 italic">No actions available</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile view -->
|
||||||
|
<div class="grid gap-4 md:hidden">
|
||||||
|
{#if loading}
|
||||||
|
{#each Array(2) as _, i (i)}
|
||||||
|
<div class="rounded-lg border p-4 space-y-3">
|
||||||
|
<Skeleton class="h-4 w-32" />
|
||||||
|
<Skeleton class="h-4 w-full" />
|
||||||
|
<Skeleton class="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{:else if summary.gift_cards.length === 0}
|
||||||
|
<div class="rounded-lg border border-dashed py-8 text-center text-gray-500">
|
||||||
|
No gift cards generated yet.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each summary.gift_cards as gc (gc.id)}
|
||||||
|
<div class="rounded-lg border p-4 space-y-3 hover:bg-gray-50">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-mono font-bold text-gray-900">{formatCardCode(gc.id)}</span>
|
||||||
|
{#if gc.redeemed_by}
|
||||||
|
<span class="inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 border border-green-200">
|
||||||
|
Claimed
|
||||||
|
</span>
|
||||||
|
{:else if gc.amount_remaining === 0}
|
||||||
|
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 border border-gray-200">
|
||||||
|
Spent
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="inline-flex items-center rounded-full bg-fuchsia-50 px-2 py-0.5 text-xs font-medium text-fuchsia-700 border border-fuchsia-200">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-xs text-gray-600 border-t border-b py-2">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-400">Total Added:</span>
|
||||||
|
<span class="font-semibold text-gray-700 ml-1">{formatCurrency(gc.total_funds_added)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-400">Remaining:</span>
|
||||||
|
<span class="font-bold text-fuchsia-800 ml-1">{formatCurrency(gc.amount_remaining)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<span class="text-gray-400">Created:</span>
|
||||||
|
<span class="font-medium text-gray-700 ml-1">{formatDate(gc.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2 pt-1">
|
||||||
|
{#if !gc.redeemed_by && gc.amount_remaining > 0}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => {
|
||||||
|
selectedCardId = gc.id;
|
||||||
|
showTopUpModal = true;
|
||||||
|
}}
|
||||||
|
class="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50 flex-1"
|
||||||
|
>
|
||||||
|
Top Up
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => {
|
||||||
|
selectedCardId = gc.id;
|
||||||
|
showTransferModal = true;
|
||||||
|
}}
|
||||||
|
class="border-pink-200 text-pink-700 hover:bg-pink-50 flex-1"
|
||||||
|
>
|
||||||
|
Transfer
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<!-- Generate Gift Card Modal -->
|
||||||
|
<Modal.Root bind:open={showGenerateModal}>
|
||||||
|
<Modal.Content class="max-w-md">
|
||||||
|
<Modal.Header>
|
||||||
|
<Modal.Title>Generate Gift Card</Modal.Title>
|
||||||
|
<Modal.Description>Generate a new gift card code with a starting balance.</Modal.Description>
|
||||||
|
</Modal.Header>
|
||||||
|
|
||||||
|
<div class="space-y-4 py-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label for="generate-amount" class="text-sm font-medium">Starting Amount (£)</label>
|
||||||
|
<Input
|
||||||
|
id="generate-amount"
|
||||||
|
type="text"
|
||||||
|
inputmode="decimal"
|
||||||
|
placeholder="e.g. 50.00"
|
||||||
|
bind:value={generateAmount}
|
||||||
|
/>
|
||||||
|
{#if generateError}
|
||||||
|
<span class="text-xs text-red-500 font-medium">{generateError}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="outline" onclick={() => showGenerateModal = false}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onclick={generateGiftCard}
|
||||||
|
disabled={creating || !isGenerateValid}
|
||||||
|
class="bg-fuchsia-600 hover:bg-fuchsia-700 text-white"
|
||||||
|
>
|
||||||
|
{creating ? 'Generating...' : 'Generate Card'}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
|
|
||||||
|
<!-- Top Up Modal -->
|
||||||
|
<Modal.Root bind:open={showTopUpModal}>
|
||||||
|
<Modal.Content class="max-w-md">
|
||||||
|
<Modal.Header>
|
||||||
|
<Modal.Title>Top Up Gift Card</Modal.Title>
|
||||||
|
<Modal.Description>Add additional funds to active, unclaimed gift card {formatCardCode(selectedCardId ?? '')}.</Modal.Description>
|
||||||
|
</Modal.Header>
|
||||||
|
|
||||||
|
<div class="space-y-4 py-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label for="topup-amount" class="text-sm font-medium">Amount to Add (£)</label>
|
||||||
|
<Input
|
||||||
|
id="topup-amount"
|
||||||
|
type="text"
|
||||||
|
inputmode="decimal"
|
||||||
|
placeholder="e.g. 20.00"
|
||||||
|
bind:value={topUpAmount}
|
||||||
|
/>
|
||||||
|
{#if topUpError}
|
||||||
|
<span class="text-xs text-red-500 font-medium">{topUpError}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="outline" onclick={() => showTopUpModal = false}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onclick={topUpCard}
|
||||||
|
disabled={toppingUp || !isTopUpValid}
|
||||||
|
class="bg-fuchsia-600 hover:bg-fuchsia-700 text-white"
|
||||||
|
>
|
||||||
|
{toppingUp ? 'Topping Up...' : 'Add Funds'}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
|
|
||||||
|
<!-- Transfer Modal -->
|
||||||
|
<Modal.Root bind:open={showTransferModal}>
|
||||||
|
<Modal.Content class="max-w-md">
|
||||||
|
<Modal.Header>
|
||||||
|
<Modal.Title>Transfer Balance</Modal.Title>
|
||||||
|
<Modal.Description>Transfer funds from {formatCardCode(selectedCardId ?? '')} directly to another card.</Modal.Description>
|
||||||
|
</Modal.Header>
|
||||||
|
|
||||||
|
<div class="space-y-4 py-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label for="transfer-code" class="text-sm font-medium">Destination Card Code</label>
|
||||||
|
<Input
|
||||||
|
id="transfer-code"
|
||||||
|
type="text"
|
||||||
|
placeholder="xxxx-xxxx-xxxx"
|
||||||
|
maxlength={14}
|
||||||
|
value={transferToCode}
|
||||||
|
oninput={handleCodeInput}
|
||||||
|
/>
|
||||||
|
{#if transferCodeError}
|
||||||
|
<span class="text-xs text-red-500 font-medium">{transferCodeError}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label for="transfer-amount" class="text-sm font-medium">Amount to Transfer (£)</label>
|
||||||
|
<Input
|
||||||
|
id="transfer-amount"
|
||||||
|
type="text"
|
||||||
|
inputmode="decimal"
|
||||||
|
placeholder="e.g. 10.00"
|
||||||
|
bind:value={transferAmount}
|
||||||
|
/>
|
||||||
|
{#if transferAmountError}
|
||||||
|
<span class="text-xs text-red-500 font-medium">{transferAmountError}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="outline" onclick={() => showTransferModal = false}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onclick={transferCard}
|
||||||
|
disabled={transferring || !isTransferValid}
|
||||||
|
class="bg-fuchsia-600 hover:bg-fuchsia-700 text-white"
|
||||||
|
>
|
||||||
|
{transferring ? 'Transferring...' : 'Transfer Balance'}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
@@ -43,6 +43,32 @@
|
|||||||
let paymentResult = $state<PaymentResult | null>(null);
|
let paymentResult = $state<PaymentResult | null>(null);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(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 = {
|
type ServiceOverride = {
|
||||||
price: string;
|
price: string;
|
||||||
originalPrice: number;
|
originalPrice: number;
|
||||||
@@ -51,6 +77,7 @@
|
|||||||
let serviceOverrides = $state<Record<string, ServiceOverride>>({});
|
let serviceOverrides = $state<Record<string, ServiceOverride>>({});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
fetchCustomerGiftCardBalance();
|
||||||
const services = booking.services ?? [];
|
const services = booking.services ?? [];
|
||||||
const overrides: Record<string, ServiceOverride> = {};
|
const overrides: Record<string, ServiceOverride> = {};
|
||||||
for (const s of services) {
|
for (const s of services) {
|
||||||
@@ -355,42 +382,105 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let giftCardId = $state('');
|
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 {
|
function formatGiftCardId(value: string): string {
|
||||||
const digits = value.replace(/\D/g, '').substring(0, 12);
|
let raw = value.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
|
||||||
const groups = digits.match(/.{1,4}/g);
|
if (raw.length > 12) raw = raw.slice(0, 12);
|
||||||
return groups ? groups.join(' ') : digits;
|
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) {
|
function handleGiftCardInput(e: Event) {
|
||||||
const input = e.target as HTMLInputElement;
|
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() {
|
async function handleGiftCardPayment() {
|
||||||
if (!giftCardValid) {
|
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;
|
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';
|
status = 'gift-confirming';
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
try {
|
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`, {
|
const response = await fetch(`/api/admin/bookings/${booking.id}/payment`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${authStore.currentToken}`
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body)
|
||||||
amount: 1000,
|
|
||||||
payment_type: 'full',
|
|
||||||
payment_method: 'giftcard',
|
|
||||||
gift_card_id: giftCardId.replace(/\s/g, '')
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -749,27 +839,70 @@
|
|||||||
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
|
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{#if booking.user_id && customerBalance > 0}
|
||||||
<label for="gift-card-id" class="text-sm font-medium text-gray-700"> Gift Card ID </label>
|
<div class="space-y-2">
|
||||||
<Input
|
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Source</span>
|
||||||
id="gift-card-id"
|
<div class="grid grid-cols-2 gap-2">
|
||||||
type="text"
|
<button
|
||||||
inputmode="text"
|
type="button"
|
||||||
tabindex={-1}
|
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {useAccountBalance
|
||||||
value={giftCardId}
|
? 'border-fuchsia-600 bg-fuchsia-50 text-fuchsia-900 font-semibold'
|
||||||
oninput={handleGiftCardInput}
|
: 'border-gray-200 hover:bg-gray-50'}"
|
||||||
placeholder="XXXX XXXX XXXX"
|
onclick={() => useAccountBalance = true}
|
||||||
maxlength={14}
|
>
|
||||||
class="mt-1 font-mono text-lg tracking-widest"
|
Account Balance ({formatCurrency(customerBalance)})
|
||||||
/>
|
</button>
|
||||||
<p class="mt-1 text-xs text-gray-500">Enter the 12-digit ID printed on the gift card</p>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {!useAccountBalance
|
||||||
|
? 'border-fuchsia-600 bg-fuchsia-50 text-fuchsia-900 font-semibold'
|
||||||
|
: 'border-gray-200 hover:bg-gray-50'}"
|
||||||
|
onclick={() => useAccountBalance = false}
|
||||||
|
>
|
||||||
|
Physical Gift Card Code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if useAccountBalance}
|
||||||
|
<div>
|
||||||
|
<label for="giftcard-amount" class="text-sm font-medium text-gray-700">Amount to pay with Balance (£)</label>
|
||||||
|
<Input
|
||||||
|
id="giftcard-amount"
|
||||||
|
type="text"
|
||||||
|
inputmode="decimal"
|
||||||
|
value={giftCardPaymentAmount}
|
||||||
|
oninput={(e) => giftCardPaymentAmount = (e.target as HTMLInputElement).value}
|
||||||
|
class="mt-1 font-mono text-lg"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Available balance: {formatCurrency(customerBalance)}. Maximum of total due or balance can be used.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div>
|
||||||
|
<label for="gift-card-id" class="text-sm font-medium text-gray-700"> Gift Card Code </label>
|
||||||
|
<Input
|
||||||
|
id="gift-card-id"
|
||||||
|
type="text"
|
||||||
|
inputmode="text"
|
||||||
|
tabindex={-1}
|
||||||
|
value={giftCardId}
|
||||||
|
oninput={handleGiftCardInput}
|
||||||
|
placeholder="XXXX-XXXX-XXXX"
|
||||||
|
maxlength={14}
|
||||||
|
class="mt-1 font-mono text-lg tracking-widest"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Enter the 12-character code printed on the gift card</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||||
<Button
|
<Button
|
||||||
onclick={handleGiftCardPayment}
|
onclick={handleGiftCardPayment}
|
||||||
class="flex-1 bg-green-600 hover:bg-green-700"
|
class="flex-1 bg-green-600 hover:bg-green-700 text-white"
|
||||||
disabled={!giftCardValid}
|
disabled={!giftCardValid}
|
||||||
>
|
>
|
||||||
Apply Gift Card
|
Apply Gift Card
|
||||||
|
|||||||
@@ -51,6 +51,43 @@
|
|||||||
let newCardCVC = $state('');
|
let newCardCVC = $state('');
|
||||||
let saveCardForFuture = $state(false);
|
let saveCardForFuture = $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 formatCardNumber(value: string): string {
|
function formatCardNumber(value: string): string {
|
||||||
const digits = value.replace(/\D/g, '').substring(0, 16);
|
const digits = value.replace(/\D/g, '').substring(0, 16);
|
||||||
const groups = digits.match(/.{1,4}/g);
|
const groups = digits.match(/.{1,4}/g);
|
||||||
@@ -59,7 +96,8 @@
|
|||||||
|
|
||||||
function handleCardNumberInput(e: Event) {
|
function handleCardNumberInput(e: Event) {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
newCardNumber = formatCardNumber(input.value);
|
const formatted = formatAndPreserveCursor(input, formatCardNumber);
|
||||||
|
newCardNumber = formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatExpiryDate(value: string): string {
|
function formatExpiryDate(value: string): string {
|
||||||
@@ -72,12 +110,14 @@
|
|||||||
|
|
||||||
function handleExpiryInput(e: Event) {
|
function handleExpiryInput(e: Event) {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
newCardExpiry = formatExpiryDate(input.value);
|
const formatted = formatAndPreserveCursor(input, formatExpiryDate);
|
||||||
|
newCardExpiry = formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCvcInput(e: Event) {
|
function handleCvcInput(e: Event) {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
newCardCVC = input.value.replace(/\D/g, '').substring(0, 4);
|
const formatted = formatAndPreserveCursor(input, (val) => val.replace(/\D/g, '').substring(0, 4));
|
||||||
|
newCardCVC = formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseExpiryParts(value: string): { month: number; year: number } | null {
|
function parseExpiryParts(value: string): { month: number; year: number } | null {
|
||||||
@@ -101,8 +141,24 @@
|
|||||||
|
|
||||||
let hasInvalidMonth = $derived(/^\d{2}\/\d{2}$/.test(newCardExpiry) && expiryParts === null);
|
let hasInvalidMonth = $derived(/^\d{2}\/\d{2}$/.test(newCardExpiry) && expiryParts === null);
|
||||||
|
|
||||||
|
function isValidLuhn(cardNumber: string): boolean {
|
||||||
|
const s = cardNumber.replace(/\D/g, '');
|
||||||
|
let sum = 0;
|
||||||
|
let alternate = false;
|
||||||
|
for (let i = s.length - 1; i >= 0; i--) {
|
||||||
|
let n = parseInt(s[i], 10);
|
||||||
|
if (alternate) {
|
||||||
|
n *= 2;
|
||||||
|
if (n > 9) n -= 9;
|
||||||
|
}
|
||||||
|
sum += n;
|
||||||
|
alternate = !alternate;
|
||||||
|
}
|
||||||
|
return sum % 10 === 0 && s.length >= 13 && s.length <= 19;
|
||||||
|
}
|
||||||
|
|
||||||
let cardFormValid = $derived(
|
let cardFormValid = $derived(
|
||||||
newCardNumber.replace(/\s/g, '').length >= 13 &&
|
isValidLuhn(newCardNumber) &&
|
||||||
expiryParts !== null &&
|
expiryParts !== null &&
|
||||||
newCardCVC.length >= 3 &&
|
newCardCVC.length >= 3 &&
|
||||||
!isExpiryInPast
|
!isExpiryInPast
|
||||||
@@ -118,8 +174,8 @@
|
|||||||
!cardSelected
|
!cardSelected
|
||||||
? selectedPaymentMethod === null && paymentMethods.length > 0 && !showNewCardForm
|
? selectedPaymentMethod === null && paymentMethods.length > 0 && !showNewCardForm
|
||||||
? 'Please select a card'
|
? 'Please select a card'
|
||||||
: newCardNumber.replace(/\s/g, '').length < 13 && newCardNumber.length > 0
|
: !isValidLuhn(newCardNumber) && newCardNumber.length > 0
|
||||||
? 'Card number too short'
|
? 'Invalid card number'
|
||||||
: hasInvalidMonth
|
: hasInvalidMonth
|
||||||
? 'Invalid expiry month'
|
? 'Invalid expiry month'
|
||||||
: isExpiryInPast
|
: isExpiryInPast
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
// shadcn-svelte components
|
// shadcn-svelte components
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
@@ -137,6 +138,10 @@
|
|||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
savedCards = await res.json();
|
savedCards = await res.json();
|
||||||
|
if (savedCards.length > 0 && !buySelectedCard) {
|
||||||
|
const defaultCard = savedCards.find((c) => c.is_default) || savedCards[0];
|
||||||
|
buySelectedCard = defaultCard.id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to load saved cards');
|
toast.error('Failed to load saved cards');
|
||||||
@@ -168,6 +173,214 @@
|
|||||||
let newCardCVC = $state('');
|
let newCardCVC = $state('');
|
||||||
let addingCard = $state(false);
|
let addingCard = $state(false);
|
||||||
|
|
||||||
|
// =============== Gift Card State ===============
|
||||||
|
let giftCardBalance = $state(0);
|
||||||
|
let loadingBalance = $state(false);
|
||||||
|
|
||||||
|
let giftCardCode = $state('');
|
||||||
|
let redeemingGiftCard = $state(false);
|
||||||
|
|
||||||
|
// Buy Gift Card State
|
||||||
|
let buyAmount = $state<10 | 20 | 50>(10);
|
||||||
|
let buyRecipientType = $state<'self' | 'friend'>('self');
|
||||||
|
let buyRecipientEmail = $state('');
|
||||||
|
let buySelectedCard = $state('');
|
||||||
|
let buyNewCardNumber = $state('');
|
||||||
|
let buyNewCardExpiry = $state('');
|
||||||
|
let buyNewCardCVC = $state('');
|
||||||
|
let buySaveCard = $state(false);
|
||||||
|
let buyingGiftCard = $state(false);
|
||||||
|
let purchaseResultCode = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function fetchGiftCardBalance() {
|
||||||
|
loadingBalance = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/user/giftcards/balance', {
|
||||||
|
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
giftCardBalance = data.balance;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
loadingBalance = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function redeemGiftCard() {
|
||||||
|
if (giftCardCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12) {
|
||||||
|
toast.error('Invalid gift card code format');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
redeemingGiftCard = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/user/giftcards/redeem', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ code: giftCardCode })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
toast.success(`Success! Redeemed ${formatCurrency(data.amount_redeemed)} to your balance.`);
|
||||||
|
giftCardCode = '';
|
||||||
|
await fetchGiftCardBalance();
|
||||||
|
} else {
|
||||||
|
const errText = await res.text();
|
||||||
|
toast.error(errText || 'Failed to redeem gift card');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Network error');
|
||||||
|
} finally {
|
||||||
|
redeemingGiftCard = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buyGiftCard() {
|
||||||
|
buyingGiftCard = true;
|
||||||
|
try {
|
||||||
|
let cardId: string | undefined;
|
||||||
|
let newCardToken: string | undefined;
|
||||||
|
let saveCard = false;
|
||||||
|
|
||||||
|
if (buySelectedCard) {
|
||||||
|
cardId = buySelectedCard;
|
||||||
|
} else if (buyNewCardNumber) {
|
||||||
|
if (!isValidLuhn(buyNewCardNumber) || !/^\d{2}\/\d{2}$/.test(buyNewCardExpiry) || buyNewCardCVC.length < 3) {
|
||||||
|
toast.error('Please enter valid credit card details');
|
||||||
|
buyingGiftCard = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newCardToken = buyNewCardNumber;
|
||||||
|
saveCard = buySaveCard;
|
||||||
|
} else {
|
||||||
|
toast.error('Please select or enter card details');
|
||||||
|
buyingGiftCard = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idempotencyKey = crypto.randomUUID();
|
||||||
|
|
||||||
|
const res = await fetch('/api/user/giftcards/buy', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
amount: buyAmount * 100, // cents
|
||||||
|
recipient_type: buyRecipientType,
|
||||||
|
recipient_email: buyRecipientEmail,
|
||||||
|
card_id: cardId,
|
||||||
|
new_card_token: newCardToken,
|
||||||
|
save_card: saveCard,
|
||||||
|
idempotency_key: idempotencyKey
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
toast.success('Gift card purchased successfully!');
|
||||||
|
purchaseResultCode = data.code;
|
||||||
|
buyNewCardNumber = '';
|
||||||
|
buyNewCardExpiry = '';
|
||||||
|
buyNewCardCVC = '';
|
||||||
|
await fetchGiftCardBalance();
|
||||||
|
if (buySelectedCard === '') {
|
||||||
|
await fetchSavedCards();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const errText = await res.text();
|
||||||
|
toast.error(errText || 'Failed to purchase gift card');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Network error');
|
||||||
|
} finally {
|
||||||
|
buyingGiftCard = 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 handleGiftCardInput(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const formatted = formatAndPreserveCursor(input, (val) => {
|
||||||
|
let raw = val.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
|
||||||
|
if (raw.length > 12) raw = raw.slice(0, 12);
|
||||||
|
let clean = '';
|
||||||
|
if (raw.length > 0) clean += raw.slice(0, 4);
|
||||||
|
if (raw.length > 4) clean += '-' + raw.slice(4, 8);
|
||||||
|
if (raw.length > 8) clean += '-' + raw.slice(8, 12);
|
||||||
|
return clean;
|
||||||
|
}, /[a-zA-Z0-9]/);
|
||||||
|
giftCardCode = formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (savedCards.length === 0 && buySelectedCard !== '') {
|
||||||
|
buySelectedCard = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidLuhn(cardNumber: string): boolean {
|
||||||
|
const s = cardNumber.replace(/\D/g, '');
|
||||||
|
let sum = 0;
|
||||||
|
let alternate = false;
|
||||||
|
for (let i = s.length - 1; i >= 0; i--) {
|
||||||
|
let n = parseInt(s[i], 10);
|
||||||
|
if (alternate) {
|
||||||
|
n *= 2;
|
||||||
|
if (n > 9) n -= 9;
|
||||||
|
}
|
||||||
|
sum += n;
|
||||||
|
alternate = !alternate;
|
||||||
|
}
|
||||||
|
return sum % 10 === 0 && s.length >= 13 && s.length <= 19;
|
||||||
|
}
|
||||||
|
|
||||||
function formatCardNumber(value: string): string {
|
function formatCardNumber(value: string): string {
|
||||||
const digits = value.replace(/\D/g, '').substring(0, 16);
|
const digits = value.replace(/\D/g, '').substring(0, 16);
|
||||||
const groups = digits.match(/.{1,4}/g);
|
const groups = digits.match(/.{1,4}/g);
|
||||||
@@ -182,9 +395,45 @@
|
|||||||
return digits;
|
return digits;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCardNumberInput(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const formatted = formatAndPreserveCursor(input, formatCardNumber);
|
||||||
|
newCardNumber = formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExpiryInput(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const formatted = formatAndPreserveCursor(input, formatExpiryDate);
|
||||||
|
newCardExpiry = formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCvcInput(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const formatted = formatAndPreserveCursor(input, (val) => val.replace(/\D/g, '').substring(0, 4));
|
||||||
|
newCardCVC = formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBuyCardNumberInput(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const formatted = formatAndPreserveCursor(input, formatCardNumber);
|
||||||
|
buyNewCardNumber = formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uses custom formatter with MM/YY slash and preserves cursor position
|
||||||
|
function handleBuyExpiryInput(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const formatted = formatAndPreserveCursor(input, formatExpiryDate);
|
||||||
|
buyNewCardExpiry = formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBuyCvcInput(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const formatted = formatAndPreserveCursor(input, (val) => val.replace(/\D/g, '').substring(0, 4));
|
||||||
|
buyNewCardCVC = formatted;
|
||||||
|
}
|
||||||
|
|
||||||
async function addCard() {
|
async function addCard() {
|
||||||
const cardNum = newCardNumber.replace(/\s/g, '');
|
if (!isValidLuhn(newCardNumber) || !/^\d{2}\/\d{2}$/.test(newCardExpiry) || newCardCVC.length < 3) {
|
||||||
if (cardNum.length < 13 || !/^\d{2}\/\d{2}$/.test(newCardExpiry) || newCardCVC.length < 3) {
|
|
||||||
toast.error('Please fill in all card details correctly');
|
toast.error('Please fill in all card details correctly');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -883,6 +1132,7 @@
|
|||||||
onclick={(_) => {
|
onclick={(_) => {
|
||||||
activeTab = 'cards';
|
activeTab = 'cards';
|
||||||
fetchSavedCards();
|
fetchSavedCards();
|
||||||
|
fetchGiftCardBalance();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -1387,6 +1637,267 @@
|
|||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
{:else if activeTab === 'cards'}
|
{:else if activeTab === 'cards'}
|
||||||
|
<!-- Gift Card Balance & Redemption -->
|
||||||
|
<div class="grid gap-6 md:grid-cols-2 mb-6">
|
||||||
|
<!-- Redeem Gift Card -->
|
||||||
|
<Card.Root class="border-fuchsia-100 bg-white">
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="text-fuchsia-900 flex items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7" />
|
||||||
|
</svg>
|
||||||
|
Redeem Gift Card
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description>Redeem a gift card directly to your account balance.</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="space-y-4">
|
||||||
|
<div class="rounded-lg border border-fuchsia-50 bg-fuchsia-50 p-4 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold text-fuchsia-600 uppercase tracking-wider">Your Balance</div>
|
||||||
|
<div class="mt-1 text-2xl font-bold text-fuchsia-950">
|
||||||
|
{loadingBalance ? '...' : formatCurrency(giftCardBalance)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl text-fuchsia-300">💰</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label for="redeem-code" class="text-sm font-medium text-gray-700">Enter Gift Card Code</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="redeem-code"
|
||||||
|
type="text"
|
||||||
|
placeholder="xxxx-xxxx-xxxx"
|
||||||
|
maxlength={14}
|
||||||
|
value={giftCardCode}
|
||||||
|
oninput={handleGiftCardInput}
|
||||||
|
class="font-mono"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onclick={redeemGiftCard}
|
||||||
|
disabled={redeemingGiftCard || giftCardCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12}
|
||||||
|
class="bg-fuchsia-600 hover:bg-fuchsia-700 text-white"
|
||||||
|
>
|
||||||
|
{redeemingGiftCard ? 'Redeeming...' : 'Redeem'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<!-- Buy Gift Card -->
|
||||||
|
<Card.Root class="border-pink-100 bg-white">
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="text-pink-900 flex items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="2" y="5" width="20" height="14" rx="2" ry="2" />
|
||||||
|
<line x1="2" y1="10" x2="22" y2="10" />
|
||||||
|
</svg>
|
||||||
|
Buy a Gift Card
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description>Purchase a gift card online for yourself or a friend.</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="space-y-4">
|
||||||
|
{#if purchaseResultCode}
|
||||||
|
<div class="rounded-lg border border-green-100 bg-green-50 p-4 space-y-3">
|
||||||
|
<div class="text-sm font-medium text-green-800 flex items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-600" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Purchase Successful!
|
||||||
|
</div>
|
||||||
|
{#if buyRecipientType === 'self'}
|
||||||
|
<p class="text-xs text-green-700">
|
||||||
|
Your purchase of <strong>{formatCurrency(buyAmount)}</strong> has been automatically added to your account balance!
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-green-700">
|
||||||
|
Here is your gift card code:
|
||||||
|
</p>
|
||||||
|
<div class="text-center py-2 bg-white rounded border border-green-200 font-mono font-bold text-lg tracking-wider text-green-800">
|
||||||
|
{formatCardCode(purchaseResultCode)}
|
||||||
|
</div>
|
||||||
|
<p class="text-[10px] text-green-600 italic">
|
||||||
|
Please save this code! It has been emailed to the recipient.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<Button size="sm" variant="outline" onclick={() => purchaseResultCode = null} class="w-full">
|
||||||
|
Buy Another Card
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Amount preset selector -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Select Value</span>
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
{#each [10, 20, 50] as amount}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border py-2.5 text-center text-sm font-semibold transition-colors {buyAmount === amount
|
||||||
|
? 'border-pink-600 bg-pink-50 text-pink-900'
|
||||||
|
: 'border-gray-200 hover:bg-gray-50'}"
|
||||||
|
onclick={() => buyAmount = amount as 10 | 20 | 50}
|
||||||
|
>
|
||||||
|
{formatCurrency(amount)}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recipient toggle -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Recipient</span>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {buyRecipientType === 'self'
|
||||||
|
? 'border-pink-600 bg-pink-50 text-pink-900'
|
||||||
|
: 'border-gray-200 hover:bg-gray-50'}"
|
||||||
|
onclick={() => buyRecipientType = 'self'}
|
||||||
|
>
|
||||||
|
For Myself (Auto-Redeem)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {buyRecipientType === 'friend'
|
||||||
|
? 'border-pink-600 bg-pink-50 text-pink-900'
|
||||||
|
: 'border-gray-200 hover:bg-gray-50'}"
|
||||||
|
onclick={() => buyRecipientType = 'friend'}
|
||||||
|
>
|
||||||
|
For a Friend (Gift Code)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if buyRecipientType === 'friend'}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label for="recipient-email" class="text-sm font-medium text-gray-700">Friend's Email (Optional)</label>
|
||||||
|
<Input
|
||||||
|
id="recipient-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="friend@example.com (blank to send to yourself)"
|
||||||
|
bind:value={buyRecipientEmail}
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Payment fields -->
|
||||||
|
<div class="space-y-3 pt-2 border-t">
|
||||||
|
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Payment Method</span>
|
||||||
|
{#if savedCards.length > 0}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each savedCards as card (card.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buySelectedCard === card.id
|
||||||
|
? 'border-pink-600 bg-pink-50'
|
||||||
|
: 'border-gray-200 hover:bg-gray-50'}"
|
||||||
|
onclick={() => {
|
||||||
|
buySelectedCard = card.id;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex h-8 min-w-12 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium uppercase text-gray-700">
|
||||||
|
{card.brand}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="font-mono">**** {card.last_4}</span>
|
||||||
|
<span class="ml-2 text-gray-400 text-xs">
|
||||||
|
Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if buySelectedCard === card.id}
|
||||||
|
<span class="text-xs font-semibold text-pink-700">Selected</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buySelectedCard === ''
|
||||||
|
? 'border-pink-600 bg-pink-50'
|
||||||
|
: 'border-gray-200 hover:bg-gray-50'}"
|
||||||
|
onclick={() => {
|
||||||
|
buySelectedCard = '';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex h-8 min-w-12 items-center justify-center rounded border-dashed border border-gray-300 text-xs font-medium text-gray-400">
|
||||||
|
NEW
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-700 animate-pulse">Use a new card</span>
|
||||||
|
</div>
|
||||||
|
{#if buySelectedCard === ''}
|
||||||
|
<span class="text-xs font-semibold text-pink-700">Selected</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if buySelectedCard === ''}
|
||||||
|
<div class="space-y-3 bg-gray-50 p-3 rounded-lg border">
|
||||||
|
<div>
|
||||||
|
<label for="buy-card-num" class="text-xs font-medium text-gray-600">Card Number</label>
|
||||||
|
<Input
|
||||||
|
id="buy-card-num"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="1234 5678 9012 3456"
|
||||||
|
value={buyNewCardNumber}
|
||||||
|
oninput={handleBuyCardNumberInput}
|
||||||
|
maxlength={19}
|
||||||
|
class="h-8 text-xs mt-1 bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label for="buy-card-exp" class="text-xs font-medium text-gray-600">Expiry (MM/YY)</label>
|
||||||
|
<Input
|
||||||
|
id="buy-card-exp"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="MM/YY"
|
||||||
|
value={buyNewCardExpiry}
|
||||||
|
oninput={handleBuyExpiryInput}
|
||||||
|
maxlength={5}
|
||||||
|
class="h-8 text-xs mt-1 bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="buy-card-cvc" class="text-xs font-medium text-gray-600">CVC</label>
|
||||||
|
<Input
|
||||||
|
id="buy-card-cvc"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="123"
|
||||||
|
value={buyNewCardCVC}
|
||||||
|
oninput={handleBuyCvcInput}
|
||||||
|
maxlength={4}
|
||||||
|
class="h-8 text-xs mt-1 bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 pt-1">
|
||||||
|
<Checkbox id="buy-save-card" bind:checked={buySaveCard} />
|
||||||
|
<label for="buy-save-card" class="text-[10px] text-gray-500">Save card for future purchases</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onclick={buyGiftCard}
|
||||||
|
disabled={buyingGiftCard || (!buySelectedCard && !buyNewCardNumber)}
|
||||||
|
class="w-full bg-pink-600 hover:bg-pink-700 text-white mt-2"
|
||||||
|
>
|
||||||
|
{buyingGiftCard ? 'Processing Payment...' : `Pay ${formatCurrency(buyAmount)}`}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Saved Cards -->
|
<!-- Saved Cards -->
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
@@ -1413,8 +1924,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
value={newCardNumber}
|
value={newCardNumber}
|
||||||
oninput={(e) =>
|
oninput={handleCardNumberInput}
|
||||||
(newCardNumber = formatCardNumber((e.target as HTMLInputElement).value))}
|
|
||||||
placeholder="1234 5678 9012 3456"
|
placeholder="1234 5678 9012 3456"
|
||||||
maxlength={19}
|
maxlength={19}
|
||||||
class="mt-1"
|
class="mt-1"
|
||||||
@@ -1430,10 +1940,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
value={newCardExpiry}
|
value={newCardExpiry}
|
||||||
oninput={(e) =>
|
oninput={handleExpiryInput}
|
||||||
(newCardExpiry = formatExpiryDate(
|
|
||||||
(e.target as HTMLInputElement).value
|
|
||||||
))}
|
|
||||||
placeholder="MM/YY"
|
placeholder="MM/YY"
|
||||||
maxlength={5}
|
maxlength={5}
|
||||||
class="mt-1"
|
class="mt-1"
|
||||||
@@ -1448,10 +1955,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
value={newCardCVC}
|
value={newCardCVC}
|
||||||
oninput={(e) =>
|
oninput={handleCvcInput}
|
||||||
(newCardCVC = (e.target as HTMLInputElement).value
|
|
||||||
.replace(/\D/g, '')
|
|
||||||
.substring(0, 4))}
|
|
||||||
placeholder="123"
|
placeholder="123"
|
||||||
maxlength={4}
|
maxlength={4}
|
||||||
class="mt-1"
|
class="mt-1"
|
||||||
@@ -1775,6 +2279,7 @@
|
|||||||
onclick={(_) => {
|
onclick={(_) => {
|
||||||
activeTab = 'cards';
|
activeTab = 'cards';
|
||||||
fetchSavedCards();
|
fetchSavedCards();
|
||||||
|
fetchGiftCardBalance();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import ServicesManagement from '$lib/components/admin/ServicesManagement.svelte';
|
import ServicesManagement from '$lib/components/admin/ServicesManagement.svelte';
|
||||||
import PatchTestsManagement from '$lib/components/admin/PatchTestsManagement.svelte';
|
import PatchTestsManagement from '$lib/components/admin/PatchTestsManagement.svelte';
|
||||||
import DiscountsManagement from '$lib/components/admin/DiscountsManagement.svelte';
|
import DiscountsManagement from '$lib/components/admin/DiscountsManagement.svelte';
|
||||||
|
import GiftCardsManagement from '$lib/components/admin/GiftCardsManagement.svelte';
|
||||||
import UserModal from '$lib/components/admin/UserModal.svelte';
|
import UserModal from '$lib/components/admin/UserModal.svelte';
|
||||||
import BookingModal from '$lib/components/admin/BookingModal.svelte';
|
import BookingModal from '$lib/components/admin/BookingModal.svelte';
|
||||||
|
|
||||||
@@ -301,6 +302,7 @@
|
|||||||
<ServicesManagement />
|
<ServicesManagement />
|
||||||
<PatchTestsManagement />
|
<PatchTestsManagement />
|
||||||
<DiscountsManagement />
|
<DiscountsManagement />
|
||||||
|
<GiftCardsManagement />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modals -->
|
<!-- Modals -->
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ CREATE SEQUENCE invoice_number_seq
|
|||||||
|
|
||||||
CREATE TABLE payments (
|
CREATE TABLE payments (
|
||||||
id CHAR(12) PRIMARY KEY DEFAULT generate_payment_id(),
|
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_type payment_type NOT NULL,
|
||||||
payment_method payment_method NOT NULL,
|
payment_method payment_method NOT NULL,
|
||||||
vendor_code TEXT,
|
vendor_code TEXT,
|
||||||
@@ -1556,6 +1556,30 @@ CREATE TABLE affiliate_payouts (
|
|||||||
|
|
||||||
CREATE INDEX idx_affiliate_payouts_affiliate ON affiliate_payouts(affiliate_id);
|
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)
|
-- SQUARE DEPOSITS TABLE (Bank Reconciliation)
|
||||||
-- =======================================
|
-- =======================================
|
||||||
|
|||||||
Reference in New Issue
Block a user