diff --git a/backend/handlers/payments/till.go b/backend/handlers/payments/till.go new file mode 100644 index 0000000..31181eb --- /dev/null +++ b/backend/handlers/payments/till.go @@ -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, ¤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', + 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"}) +} diff --git a/frontend/src/lib/components/admin/TillPurchases.svelte b/frontend/src/lib/components/admin/TillPurchases.svelte new file mode 100644 index 0000000..2902988 --- /dev/null +++ b/frontend/src/lib/components/admin/TillPurchases.svelte @@ -0,0 +1,158 @@ + + +
+
+

Till Sales

+
+ +
+ + + + + +
+ {#if showGiftCardInput} +
+
+ £ + { if (e.key === 'Enter') addGiftCard(); }} + /> +
+ +
+ {:else} + + {/if} +
+
+ + + +
+ {#if cart.length === 0} +

Tap items above to add them to the sale.

+ {:else} +
+ {#each cart as item (item.id)} +
+
+ {item.label} + {formatCurrency(item.price)} each +
+
+ + {item.qty} + + {formatCurrency(item.price * item.qty)} + +
+
+ {/each} +
+ + + +
+ + {itemCount} item{itemCount !== 1 ? 's' : ''} + + {formatCurrency(subtotal)} +
+ + +

Payment flow and backend integration coming soon.

+ {/if} +
+
diff --git a/frontend/src/lib/components/payments/TillPaymentModal.svelte b/frontend/src/lib/components/payments/TillPaymentModal.svelte new file mode 100644 index 0000000..b847d4e --- /dev/null +++ b/frontend/src/lib/components/payments/TillPaymentModal.svelte @@ -0,0 +1,1182 @@ + + + { if (!open) onClose(); }}> + + {#if step === 'success' && result} + + Payment Successful + + {action === 'create' ? 'Gift card created' : 'Gift card topped up'} and payment recorded. + + +
+
+
+ + + +
+

Payment Complete

+

+ {formatCurrency(result.total_amount)} via {result.payment_method} +

+ {#if result.payment_method === 'cash' && cashTendered > 0} +
+
+ Cash Received + {formatCurrency(cashTendered)} +
+
+ Change Due + {formatCurrency(cashTendered - result.total_amount)} +
+
+ {/if} + {#if action === 'create' && delivery === 'code'} +
+

Gift Card Code

+

+ {formatCardCode(result.giftcard_code || result.item_id || '')} +

+

Show this code to the customer

+
+ {/if} + {#if action === 'create' && delivery === 'account'} +
+

+ ✓ Gift card added to {selectedCustomer?.name || 'account'} +

+

Balance is available immediately

+
+ + {/if} +
+ +
+ {:else if step === 'error'} + + Payment Failed + +
+
+

{error || 'An error occurred'}

+
+
+ + +
+
+ {:else if step === 'customer-selection'} + + Select Customer + + Choose a customer for this {formatCurrency(amount)} {action === 'create' ? 'gift card' : 'topup'}. + + + +
+
+ Total + {formatCurrency(amount)} +
+ + +
+ + + +
+ + + {#if customerTab === 'current'} +
+ {#if currentCustomerInfo} +
+
+
+ {currentCustomerInfo.name.charAt(0).toUpperCase()} +
+
+
{currentCustomerInfo.name}
+ {#if currentCustomerInfo.email} +
{currentCustomerInfo.email}
+ {/if} +
+
+ +
+ {:else if loadingCurrentCustomer} +
+ +
+ {:else} +
+

No current appointment found.

+
+ {/if} +
+ + + {:else if customerTab === 'member'} +
+
+
+
+ + + +
+ { if (e.key === 'Enter') { e.preventDefault(); searchCustomers(1); } }} + /> +
+ +
+ +
+ {#if loadingUsers} +
+ {#each Array(3) as _, i (i)} +
+ {/each} +
+ {:else if users.length === 0} +
+ {userQuery + ? 'No customers found. Try a different search.' + : 'Search for a customer above to get started.'} +
+ {:else} +
    + {#each users as userItem (userItem.id)} +
  • + +
  • + {/each} +
+ {/if} +
+ + {#if searchTotal > 5} +
+ + Page {currentPage} of {totalPages} + +
+ {/if} +
+ + + {:else} +
+

+ A walk-in customer purchasing a gift card. No details required. +

+ +
+ {/if} + +
+ +
+
+ + {:else if step === 'payment-selection'} + + Take Payment + + Charge {formatCurrency(amount)} for {action === 'create' ? 'gift card' : 'topup'} + {#if selectedCustomer} + — {selectedCustomer.name} + {/if}. + + + +
+
+ Total + {formatCurrency(amount)} +
+ +
+ + + {#if !isGuest && savedCardList.length > 0} + + {/if} + {#if isGuest} + + {/if} +
+ +
+ +
+
+ + {:else if step === 'delivery-selection'} + + Delivery Method + + How should {selectedCustomer?.name || 'the customer'} receive their gift card? + + + +
+
+ Total + {formatCurrency(amount)} +
+ +
+ + +
+ +
+ +
+
+ + {:else if step === 'saved-card-selecting'} + + Saved Cards + + Select a saved card for {selectedCustomer?.name || 'this customer'} + + + +
+ {#if loadingSavedCards} +
+ +
+ {:else if savedCardList.length === 0} +
+

No saved cards found for this customer.

+
+ {:else} +
+ {#each savedCardList as card (card.id)} + + {/each} +
+ {/if} + +
+ + +
+
+ + {:else} + + Take Payment + + Charge {formatCurrency(amount)} for {action === 'create' ? 'gift card' : 'topup'} + {#if selectedCustomer} + — {selectedCustomer.name} + {/if}. + + + +
+
+ Total + {formatCurrency(amount)} +
+ + {#if step === 'cash-entering'} +
+ +
+ £ + cashAmount = (e.target as HTMLInputElement).value} + class="pl-7 text-lg font-semibold" + /> +
+
+ + {#if cashEntryComplete} +
+
+ Change Due + {formatCurrency(changeDue)} +
+
+ {/if} + +
+ + +
+ + {:else if step === 'cash-confirming'} +
+
+

Processing cash payment...

+
+ + {:else if step === 'card-machine'} +
+
+

Initiating card machine...

+
+ + {:else if step === 'card-polling'} +
+
+

Waiting for customer to tap card...

+

This may take a few moments

+
+
+ +
+ + {:else if step === 'card-details-entering'} +
+
+

Enter Card Details

+

Card will be charged once — not saved.

+
+
+ + +
+
+
+ + +
+
+ + +
+
+ {#if ephemeralCardError} +
{ephemeralCardError}
+ {/if} +
+
+
+ + +
+
+ {/if} +
+ {/if} +
+