package payments import ( "crussell/db" "crussell/internal/square" "crussell/internal/validators" "crussell/mw" "database/sql" "encoding/json" "errors" "fmt" "log" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" ) type TillSaleRequest struct { ItemType string `json:"item_type" validate:"required"` Action string `json:"action" validate:"required"` Amount float64 `json:"amount" validate:"required,gt=0"` GiftCardID *string `json:"gift_card_id,omitempty"` PaymentMethod string `json:"payment_method" validate:"required"` UserSavedCardID *string `json:"user_saved_card_id,omitempty"` UserID *string `json:"user_id,omitempty"` IdempotencyKey string `json:"idempotency_key,omitempty"` CardNumber string `json:"card_number,omitempty"` CardExpMonth int `json:"card_exp_month,omitempty"` CardExpYear int `json:"card_exp_year,omitempty"` CardCVC string `json:"card_cvc,omitempty"` RedeemToUserID *string `json:"redeem_to_user_id,omitempty"` } type TillSaleResponse struct { ID string `json:"id"` ItemType string `json:"item_type"` ItemID *string `json:"item_id,omitempty"` TotalAmount float64 `json:"total_amount"` PaymentMethod string `json:"payment_method"` Status string `json:"status"` CheckoutID *string `json:"checkout_id,omitempty"` } func CreateTillSale(w http.ResponseWriter, r *http.Request) { ctx := r.Context() adminID, _ := ctx.Value(mw.UserIDKey).(string) var req TillSaleRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } if err := validators.Validate.Struct(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // M8 // L5 if req.ItemType != "gift_card" { http.Error(w, "Unsupported item type", http.StatusBadRequest) return } if req.Action != "create" && req.Action != "topup" { http.Error(w, "Action must be 'create' or 'topup'", http.StatusBadRequest) return } if req.Amount <= 0 { http.Error(w, "Amount must be greater than zero", http.StatusBadRequest) return } if req.PaymentMethod != "cash" && req.PaymentMethod != "card_machine" && req.PaymentMethod != "saved_card" && req.PaymentMethod != "online_square" && req.PaymentMethod != "on_the_house" { http.Error(w, "Payment method must be 'cash', 'card_machine', 'saved_card', 'online_square', or 'on_the_house'", http.StatusBadRequest) return } if req.PaymentMethod == "saved_card" && (req.UserSavedCardID == nil || *req.UserSavedCardID == "") { http.Error(w, "user_saved_card_id is required when payment method is saved_card", http.StatusBadRequest) return } if req.PaymentMethod == "online_square" && req.CardNumber == "" { http.Error(w, "card_number is required when payment method is online_square", http.StatusBadRequest) return } if req.Action == "topup" && (req.GiftCardID == nil || *req.GiftCardID == "") { http.Error(w, "gift_card_id is required for topup", http.StatusBadRequest) return } // Idempotency check: if key provided, return existing sale if found if req.IdempotencyKey != "" { var existingID string err := db.DB.QueryRow(ctx, `SELECT id FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&existingID) if err == nil { // Existing sale found — return it (idempotent) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(TillSaleResponse{ ID: existingID, ItemType: req.ItemType, TotalAmount: req.Amount, PaymentMethod: req.PaymentMethod, Status: "completed", }) return } } service := NewPaymentService() 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 giftCardID string if req.Action == "create" { err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory) VALUES ($1, $1, $2, FALSE) RETURNING id `, req.Amount, adminID).Scan(&giftCardID) if err != nil { log.Printf("Failed to create gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } _, err = tx.Exec(ctx, ` INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) VALUES ($1, 'purchase', $2, 'till_sale', NULL, $3, NULL) `, giftCardID, req.Amount, req.UserID) if err != nil { log.Printf("Failed to create gift_card_transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } } else { cardID := validators.NormalizeGiftCardCode(*req.GiftCardID) 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 been redeemed to an account", http.StatusBadRequest) return } var isInventory bool var previousTotal float64 err = tx.QueryRow(ctx, `SELECT is_inventory, total_funds_added FROM gift_cards WHERE id = $1`, cardID).Scan(&isInventory, &previousTotal) if err != nil { log.Printf("Failed to check gift card state: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } _, err = tx.Exec(ctx, ` UPDATE gift_cards SET total_funds_added = total_funds_added + $1, amount_remaining = amount_remaining + $1, last_used_at = NOW() WHERE id = $2 `, req.Amount, cardID) if err != nil { log.Printf("Failed to top up gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } transactionType := "topup" var notes *string if isInventory && previousTotal == 0 { transactionType = "purchase" n := "first top-up on inventory card" notes = &n } _, err = tx.Exec(ctx, ` INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) VALUES ($1, $2, $3, 'till_sale', NULL, $4, $5) `, cardID, transactionType, req.Amount, req.UserID, notes) if err != nil { log.Printf("Failed to create gift_card_transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } giftCardID = cardID } // If the gift card should be immediately redeemed to a user's account balance // (e.g. admin selected "add to account" rather than "generate gift code") if req.RedeemToUserID != nil && *req.RedeemToUserID != "" { _, err = tx.Exec(ctx, ` UPDATE gift_cards SET amount_remaining = 0, redeemed_at = NOW(), redeemed_by = $1, last_used_at = NOW() WHERE id = $2 `, *req.RedeemToUserID, giftCardID) if err != nil { log.Printf("Failed to redeem gift card to user account: %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() `, *req.RedeemToUserID, req.Amount) if err != nil { log.Printf("Failed to update user gift card balance: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } } penceAmount := int64(req.Amount * 100) var squarePaymentID *string var squareCheckoutID *string var saleStatus string var dbPaymentMethod string switch req.PaymentMethod { case "cash": saleStatus = "completed" dbPaymentMethod = "cash" if req.IdempotencyKey == "" { req.IdempotencyKey = "till-cash-" + giftCardID + "-" + time.Now().Format("20060102150405.000000") } case "saved_card": dbPaymentMethod = "online_square" if req.UserID != nil && *req.UserID != "" { _, err = service.GetCardByID(ctx, *req.UserSavedCardID, *req.UserID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Saved card not found", http.StatusNotFound) return } log.Printf("Failed to verify saved card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } } var sqCardID string err = db.DB.QueryRow(ctx, ` SELECT square_card_id FROM user_saved_cards WHERE id = $1 AND deleted_at IS NULL `, *req.UserSavedCardID).Scan(&sqCardID) if err != nil { log.Printf("Failed to get saved card details: %v", err) http.Error(w, "Card not found", http.StatusNotFound) return } if req.IdempotencyKey == "" { req.IdempotencyKey = "till-sale-" + giftCardID + "-" + time.Now().Format("20060102150405.000000") } paymentReq := square.CreatePaymentReq{ Amount: penceAmount, Currency: "GBP", SourceID: sqCardID, IdempotencyKey: req.IdempotencyKey, Note: "Gift Card " + req.Action, } paymentResult, err := SquareClient.CreatePayment(ctx, paymentReq) if err != nil { log.Printf("Failed to process saved card payment: %v", err) http.Error(w, "Payment failed", http.StatusPaymentRequired) return } squarePaymentID = &paymentResult.SquarePayID saleStatus = "completed" case "card_machine": dbPaymentMethod = "in_person_card" if req.IdempotencyKey == "" { req.IdempotencyKey = "till-terminal-" + giftCardID + "-" + time.Now().Format("20060102150405.000000") } checkoutReq := square.CreateCheckoutReq{ Amount: penceAmount, Currency: "GBP", IdempotencyKey: req.IdempotencyKey, ReferenceID: giftCardID, TipEnabled: false, } checkout, err := SquareClient.CreateCheckout(ctx, checkoutReq) if err != nil { log.Printf("Failed to create Square checkout: %v", err) http.Error(w, "Failed to create card machine payment", http.StatusInternalServerError) return } squareCheckoutID = &checkout.ID saleStatus = "pending" case "online_square": dbPaymentMethod = "online_square" cardOnFile, err := SquareClient.CreateCardOnFileRaw(ctx, "till-"+giftCardID, req.CardNumber, req.CardExpMonth, req.CardExpYear, req.CardCVC) if err != nil { log.Printf("Failed to tokenize ephemeral card: %v", err) http.Error(w, "Card tokenization failed", http.StatusInternalServerError) return } if req.IdempotencyKey == "" { req.IdempotencyKey = "till-online-" + giftCardID + "-" + time.Now().Format("20060102150405.000000") } paymentReq := square.CreatePaymentReq{ Amount: penceAmount, Currency: "GBP", SourceID: cardOnFile.CardID, IdempotencyKey: req.IdempotencyKey, Note: "Gift Card " + req.Action, } paymentResult, err := SquareClient.CreatePayment(ctx, paymentReq) if err != nil { log.Printf("Failed to process online card payment: %v", err) http.Error(w, "Payment failed", http.StatusPaymentRequired) return } squarePaymentID = &paymentResult.SquarePayID saleStatus = "completed" case "on_the_house": saleStatus = "completed" dbPaymentMethod = "on_the_house" if req.IdempotencyKey == "" { req.IdempotencyKey = "till-on-the-house-" + giftCardID + "-" + time.Now().Format("20060102150405.000000") } } desc := fmt.Sprintf("Gift Card %s (£%.2f)", req.Action, req.Amount) var tillSaleID string err = tx.QueryRow(ctx, ` INSERT INTO till_sales ( item_type, item_id, description, quantity, unit_price, total_amount, payment_method, status, user_id, user_saved_card_id, square_payment_id, square_checkout_id, idempotency_key, notes, created_by, created_at, updated_at ) VALUES ($1, $2, $3, 1, $4, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW()) RETURNING id `, req.ItemType, giftCardID, desc, req.Amount, dbPaymentMethod, saleStatus, req.UserID, req.UserSavedCardID, squarePaymentID, squareCheckoutID, req.IdempotencyKey, "Admin till sale: "+req.Action+" gift card", adminID, ).Scan(&tillSaleID) if err != nil { log.Printf("Failed to insert till sale: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } _, err = tx.Exec(ctx, ` UPDATE gift_card_transactions SET reference_id = $1 WHERE gift_card_id = $2 AND reference_id IS NULL AND created_at > NOW() - INTERVAL '5 seconds' `, tillSaleID, giftCardID) if err != nil { log.Printf("Failed to update gift_card_transactions reference: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit till sale 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(TillSaleResponse{ ID: tillSaleID, ItemType: req.ItemType, ItemID: &giftCardID, TotalAmount: req.Amount, PaymentMethod: req.PaymentMethod, Status: saleStatus, CheckoutID: squareCheckoutID, }) } func GetTillCheckoutStatus(w http.ResponseWriter, r *http.Request) { checkoutID := chi.URLParam(r, "checkout_id") if checkoutID == "" { http.Error(w, "Checkout ID is required", http.StatusBadRequest) return } var tillSaleID string var currentStatus string err := db.DB.QueryRow(r.Context(), ` SELECT id, status FROM till_sales WHERE square_checkout_id = $1 `, checkoutID).Scan(&tillSaleID, ¤tStatus) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Till sale not found", http.StatusNotFound) return } log.Printf("Failed to find till sale: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if currentStatus == "completed" { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(PaymentStatusResponse{ Status: "COMPLETED", }) return } paymentResult, err := SquareClient.GetCheckout(r.Context(), checkoutID) if err != nil { if err.Error() == "checkout pending" { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(PaymentStatusResponse{Status: "PENDING"}) return } log.Printf("Failed to get checkout status: %v", err) http.Error(w, "Failed to get checkout status", http.StatusInternalServerError) return } if paymentResult.Status == "COMPLETED" { _, err = db.DB.Exec(r.Context(), ` UPDATE till_sales SET status = 'completed', square_payment_id = $1, updated_at = NOW() WHERE id = $2 `, paymentResult.SquarePayID, tillSaleID) if err != nil { log.Printf("Failed to update till sale: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(PaymentStatusResponse{ Status: "COMPLETED", PaymentID: tillSaleID, Amount: paymentResult.Amount, CardBrand: paymentResult.CardBrand, CardLast4: paymentResult.CardLast4, ReceiptURL: paymentResult.ReceiptURL, }) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(PaymentStatusResponse{Status: "PENDING"}) }