diff --git a/backend/handlers/payments/giftcards.go b/backend/handlers/payments/giftcards.go index 18dda40..9bd25ca 100644 --- a/backend/handlers/payments/giftcards.go +++ b/backend/handlers/payments/giftcards.go @@ -29,10 +29,19 @@ type GiftCard struct { RedeemedBy *string `json:"redeemed_by,omitempty"` } +type UserBalance struct { + UserID string `json:"user_id"` + Name string `json:"name"` + Email string `json:"email"` + Balance float64 `json:"balance"` + UpdatedAt time.Time `json:"updated_at"` +} + type GiftCardSummary struct { - TotalUnclaimed float64 `json:"total_unclaimed"` - TotalUserBalances float64 `json:"total_user_balances"` - GiftCards []GiftCard `json:"gift_cards"` + TotalUnclaimed float64 `json:"total_unclaimed"` + TotalUserBalances float64 `json:"total_user_balances"` + GiftCards []GiftCard `json:"gift_cards"` + UserBalances []UserBalance `json:"user_balances"` } type CreateGiftCardRequest struct { @@ -136,6 +145,37 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) { summary.GiftCards = append(summary.GiftCards, gc) } + summary.UserBalances = []UserBalance{} + ubRows, err := db.DB.Query(ctx, ` + SELECT b.user_id, u.n_first_name || ' ' || u.n_last_name AS name, u.email, b.balance, b.updated_at + FROM user_giftcard_balances b + JOIN users u ON b.user_id = u.id + ORDER BY b.updated_at DESC + `) + if err != nil { + log.Printf("Failed to query user balances: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer ubRows.Close() + + for ubRows.Next() { + var ub UserBalance + err = ubRows.Scan( + &ub.UserID, + &ub.Name, + &ub.Email, + &ub.Balance, + &ub.UpdatedAt, + ) + if err != nil { + log.Printf("Failed to scan user balance: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + summary.UserBalances = append(summary.UserBalances, ub) + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(summary) } @@ -155,8 +195,16 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) { 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 gc GiftCard - err := db.DB.QueryRow(ctx, ` + err = tx.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 @@ -173,6 +221,23 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) { return } + // Create an 'on_the_house' payment record for financial tracking + _, err = tx.Exec(ctx, ` + INSERT INTO payments (payment_type, payment_method, status, amount, created_by, created_at, updated_at) + VALUES ('full', 'on_the_house', 'completed', $1, $2, NOW(), NOW()) + `, req.Amount, adminID) + if err != nil { + log.Printf("Failed to create payment record for gift card: %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.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(gc) diff --git a/backend/handlers/payments/handlers.go b/backend/handlers/payments/handlers.go index 7ccfe2c..a6e7b0a 100644 --- a/backend/handlers/payments/handlers.go +++ b/backend/handlers/payments/handlers.go @@ -587,6 +587,25 @@ func GetUserPaymentMethods(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(cards) } +func AdminGetUserPaymentMethods(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "id") + if userID == "" || !validators.IsValidID(userID) { + http.Error(w, "Invalid user ID", http.StatusBadRequest) + return + } + + service := NewPaymentService() + cards, err := service.GetUserPaymentMethods(r.Context(), userID) + if err != nil { + log.Printf("Failed to get payment methods for user %s: %v", userID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(cards) +} + func DeletePaymentMethod(w http.ResponseWriter, r *http.Request) { cardID := chi.URLParam(r, "id") if cardID == "" || !validators.IsValidID(cardID) { diff --git a/backend/main.go b/backend/main.go index 0a11088..a5a3dfa 100644 --- a/backend/main.go +++ b/backend/main.go @@ -318,6 +318,7 @@ r.Route("/admin/users", func(r chi.Router) { r.Get("/{id}/patch-tests/eligible", user.GetEligiblePatchTestServicesHandler) r.Post("/{id}/patch-tests", user.AddPatchTestHandler) r.Get("/{id}/giftcard-balance", payments.GetUserGiftCardBalanceAdmin) + r.Get("/{id}/payment-methods", payments.AdminGetUserPaymentMethods) }) r.Route("/admin/today", func(r chi.Router) { @@ -356,6 +357,10 @@ r.Route("/admin/users", func(r chi.Router) { 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) + + // Admin till sale routes (POS transactions not linked to bookings) + r.Post("/admin/till/sale", payments.CreateTillSale) + r.Get("/admin/till/sale/checkout/{checkout_id}/status", payments.GetTillCheckoutStatus) }) }) diff --git a/frontend/src/lib/components/admin/GiftCardsManagement.svelte b/frontend/src/lib/components/admin/GiftCardsManagement.svelte index 8f291b1..95a332b 100644 --- a/frontend/src/lib/components/admin/GiftCardsManagement.svelte +++ b/frontend/src/lib/components/admin/GiftCardsManagement.svelte @@ -6,6 +6,7 @@ import { Input } from '$lib/components/ui/input'; import * as Modal from '$lib/components/ui/dialog'; import { Skeleton } from '$lib/components/ui/skeleton'; + import TillPaymentModal from '$lib/components/payments/TillPaymentModal.svelte'; interface GiftCard { id: string; @@ -17,18 +18,30 @@ redeemed_by?: string; } + interface UserBalance { + user_id: string; + name: string; + email: string; + balance: number; + updated_at: string; + } + interface GiftCardSummary { total_unclaimed: number; total_user_balances: number; gift_cards: GiftCard[]; + user_balances: UserBalance[]; } let summary = $state({ total_unclaimed: 0, total_user_balances: 0, - gift_cards: [] + gift_cards: [], + user_balances: [] }); + let activeSection = $state<'cards' | 'balances'>('cards'); + let loading = $state(true); let showGenerateModal = $state(false); let showTopUpModal = $state(false); @@ -72,6 +85,18 @@ let isTopUpValid = $derived(topUpAmount && !topUpError); let isTransferValid = $derived(transferAmount && !transferAmountError && transferToCode && !transferCodeError); + // Choice flow state + let generateStep = $state<'choice' | 'amount'>('choice'); + let generateMode = $state<'giveaway' | 'purchase'>('giveaway'); + let topUpStep = $state<'choice' | 'amount'>('choice'); + let topUpMode = $state<'giveaway' | 'purchase'>('giveaway'); + + // TillPayment state + let showTillPayment = $state(false); + let tillAmount = $state(0); + let tillAction = $state<'create' | 'topup'>('create'); + let tillGiftCardId = $state(undefined); + async function fetchGiftCards() { loading = true; try { @@ -92,6 +117,14 @@ } } + function goToGeneratePayment() { + if (!isGenerateValid) return; + tillAmount = Number(generateAmount); + tillAction = 'create'; + tillGiftCardId = undefined; + showTillPayment = true; + } + async function generateGiftCard() { if (!isGenerateValid) return; creating = true; @@ -108,7 +141,7 @@ const data = await res.json(); toast.success(`Gift card ${formatCardCode(data.id)} generated successfully!`); showGenerateModal = false; - generateAmount = ''; + resetGenerateModal(); await fetchGiftCards(); } else { const errText = await res.text(); @@ -121,6 +154,14 @@ } } + function goToTopUpPayment() { + if (!isTopUpValid) return; + tillAmount = Number(topUpAmount); + tillAction = 'topup'; + tillGiftCardId = selectedCardId ?? undefined; + showTillPayment = true; + } + async function topUpCard() { if (!isTopUpValid || !selectedCardId) return; toppingUp = true; @@ -136,7 +177,7 @@ if (res.ok) { toast.success('Gift card topped up successfully'); showTopUpModal = false; - topUpAmount = ''; + resetTopUpModal(); await fetchGiftCards(); } else { const errText = await res.text(); @@ -181,6 +222,77 @@ } } + function resetGenerateModal() { + generateStep = 'choice'; + generateMode = 'giveaway'; + generateAmount = ''; + } + + function resetTopUpModal() { + topUpStep = 'choice'; + topUpMode = 'giveaway'; + topUpAmount = ''; + } + + async function handleTillPaymentComplete(result: { id: string; total_amount: number; payment_method: string; status: string }) { + if (tillAction === 'create') { + 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!`); + showTillPayment = false; + showGenerateModal = false; + resetGenerateModal(); + 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; + } + } else { + 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'); + showTillPayment = false; + showTopUpModal = false; + resetTopUpModal(); + 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; + } + } + } + function handleCodeInput(e: Event) { const target = e.target as HTMLInputElement; let raw = target.value.replace(/[^a-zA-Z0-9]/g, '').toLowerCase(); @@ -209,6 +321,72 @@ }); } + // =============== Sorting =============== + type SortKey = 'code' | 'added' | 'remaining' | 'created' | 'status' | 'name' | 'email' | 'balance' | 'updated'; + + let cardSort = $state<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'created', dir: 'desc' }); + let balanceSort = $state<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'updated', dir: 'desc' }); + + function toggleCardSort(key: SortKey) { + if (cardSort.key === key) { + cardSort.dir = cardSort.dir === 'asc' ? 'desc' : 'asc'; + } else { + cardSort.key = key; + cardSort.dir = 'asc'; + } + } + + function toggleBalanceSort(key: SortKey) { + if (balanceSort.key === key) { + balanceSort.dir = balanceSort.dir === 'asc' ? 'desc' : 'asc'; + } else { + balanceSort.key = key; + balanceSort.dir = 'asc'; + } + } + + let sortedCards = $derived.by(() => { + const cards = [...summary.gift_cards]; + const { key, dir } = cardSort; + const mul = dir === 'asc' ? 1 : -1; + cards.sort((a, b) => { + switch (key) { + case 'code': return a.id.localeCompare(b.id) * mul; + case 'added': return (a.total_funds_added - b.total_funds_added) * mul; + case 'remaining': return (a.amount_remaining - b.amount_remaining) * mul; + case 'created': return (new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) * mul; + case 'status': { + const aVal = a.redeemed_by ? 2 : a.amount_remaining === 0 ? 1 : 0; + const bVal = b.redeemed_by ? 2 : b.amount_remaining === 0 ? 1 : 0; + return (aVal - bVal) * mul; + } + default: return 0; + } + }); + return cards; + }); + + let sortedBalances = $derived.by(() => { + const bals = [...summary.user_balances]; + const { key, dir } = balanceSort; + const mul = dir === 'asc' ? 1 : -1; + bals.sort((a, b) => { + switch (key) { + case 'name': return a.name.localeCompare(b.name) * mul; + case 'email': return a.email.localeCompare(b.email) * mul; + case 'balance': return (a.balance - b.balance) * mul; + case 'updated': return (new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime()) * mul; + default: return 0; + } + }); + return bals; + }); + + function sortArrow(key: SortKey, state: typeof cardSort): string { + if (state.key !== key) return ''; + return state.dir === 'asc' ? ' \u25B2' : ' \u25BC'; + } + $effect(() => { if (authStore.currentToken) { fetchGiftCards(); @@ -225,18 +403,7 @@ Create, top-up, and track gift cards. Unclaimed cards can be topped up or transferred. - @@ -244,194 +411,312 @@
-
-
Total Unclaimed
-
+
+
Total Unclaimed
+
{loading ? '...' : formatCurrency(summary.total_unclaimed)}
-
-
User Account Balances
-
+
+
User Account Balances
+
{loading ? '...' : formatCurrency(summary.total_user_balances)}
-
-
Combined Liability
-
+
+
Combined Liability
+
{loading ? '...' : formatCurrency(summary.total_unclaimed + summary.total_user_balances)}
-