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
|
||||
}
|
||||
Reference in New Issue
Block a user