Add giftcard management

This commit is contained in:
2026-06-06 14:26:42 +01:00
parent 310e6aaa80
commit 5258f90b06
3 changed files with 1748 additions and 0 deletions
+408
View File
@@ -0,0 +1,408 @@
package payments
import (
"crussell/db"
"crussell/internal/square"
"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"`
Action string `json:"action"`
Amount float64 `json:"amount"`
GiftCardID *string `json:"gift_card_id,omitempty"`
PaymentMethod string `json:"payment_method"`
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 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" {
http.Error(w, "Payment method must be 'cash', 'card_machine', 'saved_card', or 'online_square'", 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
}
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)
VALUES ($1, $1, $2)
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
}
} else {
cardID := normalizeCode(*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
}
_, err = tx.Exec(ctx, `
UPDATE gift_cards
SET total_funds_added = total_funds_added + $1,
amount_remaining = amount_remaining + $1
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
}
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
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"
}
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
}
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, &currentStatus)
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',
card_last4 = $1,
card_brand = $2,
square_payment_id = $3,
updated_at = NOW()
WHERE id = $4
`, paymentResult.CardLast4, paymentResult.CardBrand, 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"})
}