From 31cc3ef5c301385374fdf9ad8b7ffde6dff51e1e Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 18 Jun 2026 16:26:29 +0100 Subject: [PATCH] feat(backend): update payments handlers and service Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/payments/giftcards.go | 141 ++-- backend/handlers/payments/handlers.go | 866 ++++++++++++++++++++- backend/handlers/payments/payments_test.go | 664 +++++++++++++++- backend/handlers/payments/service.go | 148 ++-- backend/handlers/payments/till.go | 16 +- backend/handlers/payments/validators.go | 2 +- 6 files changed, 1669 insertions(+), 168 deletions(-) diff --git a/backend/handlers/payments/giftcards.go b/backend/handlers/payments/giftcards.go index 7ff0637..82abd83 100644 --- a/backend/handlers/payments/giftcards.go +++ b/backend/handlers/payments/giftcards.go @@ -23,15 +23,15 @@ import ( // --- 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"` - IsInventory bool `json:"is_inventory"` - LastUsedAt *time.Time `json:"last_used_at,omitempty"` + 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"` + IsInventory bool `json:"is_inventory"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` } type UserBalance struct { @@ -52,6 +52,7 @@ type GiftCardListResponse struct { Page int `json:"page"` PerPage int `json:"perPage"` TotalPages int `json:"totalPages"` + NextCursor *string `json:"next_cursor,omitempty"` } type CreateGiftCardRequest struct { @@ -93,14 +94,8 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) { searchTerm := query.Get("q") // Pagination parameters - page := 1 perPage := 10 - - if pageStr := query.Get("page"); pageStr != "" { - if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { - page = p - } - } + cursorStr := query.Get("cursor") if perPageStr := query.Get("per_page"); perPageStr != "" { if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 { @@ -108,7 +103,13 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) { } } - offset := (page - 1) * perPage + // Accept page param for backward compat (deprecated) + page := 1 + if pageStr := query.Get("page"); pageStr != "" { + if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { + page = p + } + } var resp GiftCardListResponse resp.GiftCards = []GiftCard{} @@ -158,33 +159,41 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) { } var gcTotal int - var gcCountArgs []interface{} var gcListArgs []interface{} - gcCountQuery := fmt.Sprintf(`SELECT COUNT(*) FROM gift_cards %s`, whereSQL) gcListQuery := fmt.Sprintf(` - SELECT id, total_funds_added, amount_remaining, created_by, created_at, redeemed_at, redeemed_by, is_inventory, last_used_at + SELECT id, total_funds_added, amount_remaining, created_at, is_inventory FROM gift_cards %s - ORDER BY created_at DESC - LIMIT $%%d OFFSET $%%d `, whereSQL) if searchTerm != "" { searchPattern := "%" + searchTerm + "%" - gcCountArgs = []interface{}{searchPattern} - gcListArgs = []interface{}{searchPattern, perPage, offset} - gcListQuery = fmt.Sprintf(gcListQuery, 2, 3) - } else { - gcListArgs = []interface{}{perPage, offset} - gcListQuery = fmt.Sprintf(gcListQuery, 1, 2) - } + gcListArgs = []interface{}{searchPattern} - err = db.DB.QueryRow(ctx, gcCountQuery, gcCountArgs...).Scan(&gcTotal) - if err != nil { - log.Printf("Failed to count gift cards: %v", err) - http.Error(w, "internal server error", http.StatusInternalServerError) - return + if cursorStr != "" { + cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr) + if err != nil { + http.Error(w, "invalid cursor: "+err.Error(), http.StatusBadRequest) + return + } + gcListQuery += " AND (created_at, id) < ($2, $3)" + gcListArgs = append(gcListArgs, cursorCreatedAt, cursorID) + } + gcListQuery += " ORDER BY created_at DESC, id DESC LIMIT $" + strconv.Itoa(len(gcListArgs)+1) + gcListArgs = append(gcListArgs, perPage+1) + } else { + if cursorStr != "" { + cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr) + if err != nil { + http.Error(w, "invalid cursor: "+err.Error(), http.StatusBadRequest) + return + } + gcListQuery += " WHERE (created_at, id) < ($1, $2)" + gcListArgs = append(gcListArgs, cursorCreatedAt, cursorID) + } + gcListQuery += " ORDER BY created_at DESC, id DESC LIMIT $" + strconv.Itoa(len(gcListArgs)+1) + gcListArgs = append(gcListArgs, perPage+1) } gcRows, err := db.DB.Query(ctx, gcListQuery, gcListArgs...) @@ -195,21 +204,27 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) { } defer gcRows.Close() + // Count query: total matching gift cards (same WHERE, without cursor/ORDER BY/LIMIT). + gcTotal = 0 + if whereSQL != "" { + countArgs := []interface{}{} + if searchTerm != "" { + countArgs = append(countArgs, "%"+searchTerm+"%") + } + db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards "+whereSQL, countArgs...).Scan(&gcTotal) + } else { + db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards").Scan(&gcTotal) + } + for gcRows.Next() { var gc GiftCard - var createdBy, redeemedBy sql.NullString - var redeemedAt, lastUsedAt sql.NullTime err = gcRows.Scan( &gc.ID, &gc.TotalFundsAdded, &gc.AmountRemaining, - &createdBy, &gc.CreatedAt, - &redeemedAt, - &redeemedBy, &gc.IsInventory, - &lastUsedAt, ) if err != nil { log.Printf("Failed to scan gift card: %v", err) @@ -217,19 +232,6 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) { return } - if createdBy.Valid { - gc.CreatedBy = &createdBy.String - } - if redeemedBy.Valid { - gc.RedeemedBy = &redeemedBy.String - } - if redeemedAt.Valid { - gc.RedeemedAt = &redeemedAt.Time - } - if lastUsedAt.Valid { - gc.LastUsedAt = &lastUsedAt.Time - } - resp.GiftCards = append(resp.GiftCards, gc) } @@ -243,7 +245,7 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) { searchPattern := "%" + searchTerm + "%" ubListQuery = ` - SELECT COUNT(*) OVER() AS total_count, + 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 @@ -255,7 +257,7 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) { ubListArgs = []interface{}{searchPattern} } else { ubListQuery = ` - SELECT COUNT(*) OVER() AS total_count, + 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 @@ -272,11 +274,18 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) { } defer ubRows.Close() + // Count query for user balances. + if searchTerm != "" { + db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM user_giftcard_balances b + JOIN users u ON b.user_id = u.id + WHERE u.n_first_name ILIKE $1 OR u.n_last_name ILIKE $1 OR u.email ILIKE $1`, "%"+searchTerm+"%").Scan(&ubTotal) + } else { + db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM user_giftcard_balances").Scan(&ubTotal) + } + for ubRows.Next() { var ub UserBalance - var rowTotal int err = ubRows.Scan( - &rowTotal, &ub.UserID, &ub.Name, &ub.Email, @@ -288,15 +297,21 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal server error", http.StatusInternalServerError) return } - if ubTotal == 0 { - ubTotal = rowTotal - } resp.UserBalances = append(resp.UserBalances, ub) } resp.UBTotal = ubTotal resp.Total = gcTotal + var nextCursor *string + if len(resp.GiftCards) > perPage { + resp.GiftCards = resp.GiftCards[:perPage] + last := resp.GiftCards[len(resp.GiftCards)-1] + cursor := last.CreatedAt.Format(time.RFC3339) + "|" + last.ID + nextCursor = &cursor + } + resp.NextCursor = nextCursor + totalPages := (gcTotal + perPage - 1) / perPage if totalPages == 0 { totalPages = 1 @@ -711,7 +726,7 @@ func RedeemGiftCard(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ - "status": "success", + "status": "success", "amount_redeemed": amountRemaining, }) } @@ -984,15 +999,13 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]interface{}{ "status": "success", - "code": cardID, + "code": cardID, "amount": amountPounds, }) } // --- Helpers --- - - type ExpiredBalance struct { ID string `json:"id"` AccountID *string `json:"account_id,omitempty"` diff --git a/backend/handlers/payments/handlers.go b/backend/handlers/payments/handlers.go index 18c20e4..11d0536 100644 --- a/backend/handlers/payments/handlers.go +++ b/backend/handlers/payments/handlers.go @@ -1,6 +1,7 @@ package payments import ( + "context" "crussell/db" "crussell/internal/square" "crussell/internal/validators" @@ -8,7 +9,9 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "log" + "math" "net/http" "strconv" "strings" @@ -19,8 +22,8 @@ import ( ) type CreateTerminalPaymentRequest struct { - Amount int64 `json:"amount"` - PaymentType string `json:"payment_type"` + Amount int64 `json:"amount" validate:"required,gt=0"` + PaymentType string `json:"payment_type" validate:"required"` OverrideAmount *int64 `json:"override_amount,omitempty"` TipEnabled bool `json:"tip_enabled"` PaymentMethod *string `json:"payment_method,omitempty"` @@ -28,12 +31,12 @@ type CreateTerminalPaymentRequest struct { } type CreateBookingPaymentRequest struct { - Amount int64 `json:"amount"` - PaymentType string `json:"payment_type"` + Amount int64 `json:"amount" validate:"required,gt=0"` + PaymentType string `json:"payment_type" validate:"required"` CardID *string `json:"card_id,omitempty"` NewCardToken *string `json:"new_card_token,omitempty"` SaveCard bool `json:"save_card"` - IdempotencyKey string `json:"idempotency_key"` + IdempotencyKey string `json:"idempotency_key" validate:"required"` } type RefundRequest struct { @@ -42,8 +45,8 @@ type RefundRequest struct { } type CreateTipPaymentRequest struct { - Amount int64 `json:"amount"` - CardToken string `json:"card_token"` + Amount int64 `json:"amount" validate:"required,gt=0"` + CardToken string `json:"card_token" validate:"required"` } type CheckoutResponse struct { @@ -90,6 +93,183 @@ type PaymentSummaryResponse struct { Refunds []RefundResponse `json:"refunds"` } +// DiscountPreviewResponse describes eligible discounts for a booking. +type DiscountPreviewResponse struct { + Eligible bool `json:"eligible"` + Discounts []DiscountPreview `json:"discounts"` + OriginalTotal float64 `json:"original_total"` + DiscountedTotal float64 `json:"discounted_total"` +} + +// DiscountPreview describes a single eligible discount. +type DiscountPreview struct { + Source string `json:"source"` + Name string `json:"name"` + Percent float64 `json:"percent"` + Amount float64 `json:"amount"` +} + +// GetDiscountPreviewHandler returns eligible discounts for a booking without applying them. +// GET /api/bookings/{id}/discount-preview +func GetDiscountPreviewHandler(w http.ResponseWriter, r *http.Request) { + bookingID := chi.URLParam(r, "id") + if !validators.IsValidID(bookingID) { + http.Error(w, "Invalid booking ID", http.StatusBadRequest) + return + } + + userID, ok := mw.GetUserID(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + preview := calculateDiscountPreview(r.Context(), bookingID, userID) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(preview) +} + +// calculateDiscountPreview runs the same queries as applyEligibleCampaignsAtPayment +// but returns the results without inserting any records. +func calculateDiscountPreview(ctx context.Context, bookingID string, userID string) DiscountPreviewResponse { + resp := DiscountPreviewResponse{ + Discounts: []DiscountPreview{}, + } + + var bookingTotal float64 + db.DB.QueryRow(ctx, ` + SELECT COALESCE(SUM(price_val), 0) FROM ( + SELECT COALESCE(bs.override_price, s.price) AS price_val + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + UNION ALL + SELECT COALESCE(bcs.override_price, cs.price) + FROM booking_custom_services bcs + JOIN custom_services cs ON bcs.custom_service_id = cs.id + WHERE bcs.booking_id = $1 + ) sub + `, bookingID).Scan(&bookingTotal) + + if bookingTotal <= 0 { + return resp + } + + resp.OriginalTotal = bookingTotal + discountTotal := 0.0 + + var campaignID string + var campaignPercent float64 + var campaignName string + if err := db.DB.QueryRow(ctx, ` + SELECT id, discount_percent, name FROM discount_campaigns + WHERE status = 'active' AND campaign_type = 'time_based' + AND start_date <= NOW() AND end_date >= NOW() + AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) + ORDER BY discount_percent DESC LIMIT 1 + `).Scan(&campaignID, &campaignPercent, &campaignName); err == nil && campaignID != "" { + var exists int + db.DB.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, campaignID).Scan(&exists) + if exists == 0 { + amount := roundTo2(bookingTotal * campaignPercent / 100) + resp.Discounts = append(resp.Discounts, DiscountPreview{ + Source: "campaign", + Name: campaignName, + Percent: campaignPercent, + Amount: amount, + }) + discountTotal += amount + } + } + + var userBookingCount int + db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&userBookingCount) + + var milestoneCampaignID string + var milestonePercent float64 + var milestoneName string + db.DB.QueryRow(ctx, ` + SELECT id, discount_percent, name FROM discount_campaigns + WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count' + AND milestone_value = $1 + AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $2 AND source_id = discount_campaigns.id) + `, userBookingCount, userID).Scan(&milestoneCampaignID, &milestonePercent, &milestoneName) + + if milestoneCampaignID != "" { + var exists int + db.DB.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, milestoneCampaignID).Scan(&exists) + if exists == 0 { + amount := roundTo2(bookingTotal * milestonePercent / 100) + resp.Discounts = append(resp.Discounts, DiscountPreview{ + Source: "campaign", + Name: milestoneName, + Percent: milestonePercent, + Amount: amount, + }) + discountTotal += amount + } + } + + var firstVisitDate time.Time + db.DB.QueryRow(ctx, `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&firstVisitDate) + if !firstVisitDate.IsZero() { + type annCamp struct { + id string + pct float64 + value int + unit string + name string + } + annRows, err := db.DB.Query(ctx, ` + SELECT id, discount_percent, milestone_value, milestone_unit, name FROM discount_campaigns + WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'anniversary' + AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary') + `, userID) + if err == nil { + var campaigns []annCamp + for annRows.Next() { + var c annCamp + if annRows.Scan(&c.id, &c.pct, &c.value, &c.unit, &c.name) == nil { + campaigns = append(campaigns, c) + } + } + annRows.Close() + + for _, c := range campaigns { + var exists int + db.DB.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, c.id).Scan(&exists) + if exists > 0 { + continue + } + + var matches bool + elapsed := time.Since(firstVisitDate) + switch c.unit { + case "months": + matches = int(elapsed.Hours()/(30*24)) >= c.value + case "years": + matches = int(elapsed.Hours()/(365.25*24)) >= c.value + } + if matches { + amount := roundTo2(bookingTotal * c.pct / 100) + resp.Discounts = append(resp.Discounts, DiscountPreview{ + Source: "campaign", + Name: c.name, + Percent: c.pct, + Amount: amount, + }) + discountTotal += amount + } + } + } + } + + resp.Eligible = len(resp.Discounts) > 0 + resp.DiscountedTotal = roundTo2(bookingTotal - discountTotal) + return resp +} + func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" || !validators.IsValidID(bookingID) { @@ -110,6 +290,14 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { return } + if err := validators.Validate.Struct(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // M8 + // L5 + if err := ValidateAmount(req.Amount); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -312,12 +500,20 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) { http.Error(w, "Checkout ID is required", http.StatusBadRequest) return } + if !validators.IsValidID(checkoutID) { + http.Error(w, "not found", http.StatusNotFound) + return + } bookingID := r.URL.Query().Get("booking_id") if bookingID == "" { http.Error(w, "booking_id query parameter is required", http.StatusBadRequest) return } + if !validators.IsValidID(bookingID) { + http.Error(w, "Invalid booking ID", http.StatusBadRequest) + return + } paymentResult, err := SquareClient.GetCheckout(r.Context(), checkoutID) if err != nil { @@ -388,6 +584,19 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) { http.Error(w, "Payment failed", http.StatusPaymentRequired) } +// IsValidBookingStatusForPayment returns true if the booking status allows +// accepting payments. This guard prevents racing with CleanupExpiredDeposits — +// once a booking's slot has been released (deposit_lapsed, etc.), +// we must reject the payment before hitting Square's API. +func IsValidBookingStatusForPayment(status string) bool { + switch status { + case "confirmed", "pending", "pending_release", "in_progress": + return true + default: + return false + } +} + func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" || !validators.IsValidID(bookingID) { @@ -408,6 +617,14 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { return } + if err := validators.Validate.Struct(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // M8 + // L5 + if err := ValidateAmount(req.Amount); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -454,6 +671,59 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { return } + // Serialize payment attempts for this booking to prevent concurrent payments + // across browser tabs or duplicate requests. Uses a PostgreSQL session-level + // advisory lock so that only one goroutine processes payment for a given + // booking at a time, even if two requests pass the optimistic status check below. + // + // We acquire a dedicated connection from the pool and hold it for the + // duration of the handler so that lock and unlock use the same connection. + // Using db.DB.Exec() for both would be unsafe — each call may get a + // different pool connection, and pg_advisory_unlock on a different session + // is a silent no-op, leaking the lock. + pinConn, err := db.DB.Acquire(r.Context()) + if err != nil { + log.Printf("Failed to acquire connection for payment lock: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer pinConn.Release() + + if _, err := pinConn.Exec(r.Context(), ` + SELECT pg_advisory_lock(hashtext('crussell:payment:' || $1)) + `, bookingID); err != nil { + log.Printf("Failed to acquire payment serialization lock for %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer func() { + if _, err := pinConn.Exec(context.Background(), ` + SELECT pg_advisory_unlock(hashtext('crussell:payment:' || $1)) + `, bookingID); err != nil { + log.Printf("Failed to release payment serialization lock for %s: %v", bookingID, err) + } + }() + + // Now that we hold the serialization lock, re-check the booking status. + // If another request (e.g. from a different tab) already processed a payment + // and promoted the booking while we were waiting, we see that here. + status, err := service.GetBookingStatus(r.Context(), bookingID) + if err != nil { + log.Printf("Failed to get booking status for payment check: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if !IsValidBookingStatusForPayment(status) { + log.Printf("Payment rejected: booking %s is in status %q (no longer accepting payments)", bookingID, status) + http.Error(w, "This booking is no longer accepting payments. The slot may have been released.", http.StatusConflict) + return + } + if status == "pending" { + log.Printf("Payment rejected: booking %s is 'pending' — must be confirmed first", bookingID) + http.Error(w, "This booking has not been confirmed yet. Please wait for the booking to be confirmed before making a payment.", http.StatusConflict) + return + } + existingPayment, err := service.CheckIdempotency(r.Context(), bookingID, req.IdempotencyKey) if err != nil { log.Printf("Failed to check idempotency: %v", err) @@ -471,6 +741,31 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { return } + // After the idempotency check (which handles same-key retries), verify + // that no completed payment of the same non-partial type already exists. + // buildSplitRecords converts 'full' and 'deposit' input types into a + // 'deposit' PB record, so we also check for an existing deposit when the + // incoming type is 'full' or 'deposit'. Together with the advisory lock, + // this prevents the two-tab race where different idempotency keys allow + // concurrent payments of the same type. + if req.PaymentType != "partial" { + var existingCount int + if err := db.DB.QueryRow(r.Context(), ` + SELECT COUNT(*) FROM payments + WHERE booking_id = $1 + AND status = 'completed' + AND payment_method NOT IN ('discount', 'on_the_house') + AND ( + payment_type = $2 + OR ($2 IN ('full', 'deposit') AND payment_type = 'deposit') + ) + `, bookingID, req.PaymentType).Scan(&existingCount); err == nil && existingCount > 0 { + log.Printf("Payment rejected: booking %s already has a completed %q payment", bookingID, req.PaymentType) + http.Error(w, "A payment of this type has already been processed for this booking", http.StatusConflict) + return + } + } + var sourceID string var savedCardID *string @@ -492,6 +787,9 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { savedCardID = &cardID } } + if savedCardID == nil && req.SaveCard { + log.Printf("Card was not saved despite save_card=true for user %s", userID) + } } else if req.CardID != nil { card, err := service.GetCardByID(r.Context(), *req.CardID, userID) if err != nil { @@ -523,14 +821,27 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { return } + // Apply eligible campaign discounts before the payment is processed, so + // the discount payment records exist in the DB before the frontend computes + // the net amount to charge. The call is idempotent — if discounts were + // already applied (e.g. by a prior call), the duplicate check skips them. + applyEligibleCampaignsAtPayment(r.Context(), bookingID, userID) + fees := service.CalculateFees(req.Amount, "online") - record := PaymentRecord{ + paymentAmount := float64(req.Amount) / 100.0 + + // Build payment records — may split a single Square charge into + // a deposit portion (up to 50% of booking total) plus a balance + // portion, so the refund system can correctly track deposit vs + // non-deposit money per the deposit protection policy. + bookingInfo, bErr := service.GetBookingPaymentInfo(r.Context(), bookingID) + primaryRecord := PaymentRecord{ BookingID: bookingID, PaymentType: req.PaymentType, PaymentMethod: "online_square", Status: "completed", - Amount: float64(req.Amount) / 100.0, + Amount: paymentAmount, SquarePaymentID: &paymentResult.SquarePayID, IdempotencyKey: &req.IdempotencyKey, Fees: float64(fees) / 100.0, @@ -540,23 +851,87 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { CreatedBy: &userID, } - paymentID, err := service.CreatePaymentRecord(r.Context(), record, nil) - if err != nil { - log.Printf("Failed to create payment record: %v", err) + var records []PaymentRecord + if bErr == nil && bookingInfo != nil { + records = buildSplitRecords(primaryRecord, req.PaymentType, bookingInfo, paymentAmount) + } else { + if bErr != nil { + log.Printf("Failed to get booking info for split: %v — using single record", bErr) + } + records = []PaymentRecord{primaryRecord} + } + + // Create all payment records for this Square charge inside a transaction + // so that if any insert fails the entire group rolls back. This prevents + // a data inconsistency where Square charged the customer but only part of + // the split is reflected in the DB. + tx, txErr := db.DB.Begin(r.Context()) + if txErr != nil { + log.Printf("Failed to begin transaction for payment records: %v", txErr) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + + var primaryPaymentID string + for i, rec := range records { + pid, cErr := service.CreatePaymentRecordTx(r.Context(), tx, rec, nil) + if cErr != nil { + log.Printf("Failed to create payment record %d/%d: %v", i+1, len(records), cErr) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if i == 0 { + primaryPaymentID = pid + } + } + + // Promote deposit to confirmed if total paid meets the 20% threshold. + // Check is inside the transaction so it sees the just-inserted payments. + var depositMet bool + tx.QueryRow(r.Context(), ` + WITH booking_total AS ( + SELECT COALESCE(SUM(price_val), 0) * 100 AS total_cents FROM ( + SELECT COALESCE(bs.override_price, s.price) AS price_val + FROM booking_services bs + LEFT JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + UNION ALL + SELECT COALESCE(bcs.override_price, cs.price) + FROM booking_custom_services bcs + LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id + WHERE bcs.booking_id = $1 + ) price_sub + ), + paid_total AS ( + SELECT COALESCE(SUM(amount), 0) * 100 AS paid_cents + FROM payments + WHERE booking_id = $1 AND status = 'completed' + ) + SELECT pt.paid_cents >= ROUND(bt.total_cents * 0.2) + FROM booking_total bt, paid_total pt + `, bookingID).Scan(&depositMet) + + if depositMet { + if _, err := tx.Exec(r.Context(), ` + UPDATE bookings SET status = 'confirmed', updated_at = NOW() + WHERE id = $1 AND status = 'pending_release' + `, bookingID); err != nil { + log.Printf("ALERT: payment recorded but failed to promote booking %s from pending_release: %v", bookingID, err) + } + } + + if cErr := tx.Commit(r.Context()); cErr != nil { + log.Printf("Failed to commit payment records: %v", cErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } - if req.PaymentType == "deposit" { - err = service.UpdateBookingDepositPaid(r.Context(), bookingID, true) - if err != nil { - log.Printf("Failed to update deposit paid: %v", err) - } - } + applyEligibleCampaignsAtPayment(r.Context(), bookingID, userID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(PaymentResponse{ - ID: paymentID, + ID: primaryPaymentID, BookingID: bookingID, PaymentType: req.PaymentType, Status: "completed", @@ -568,6 +943,330 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { }) } +// applyEligibleCampaignsAtPayment runs after a payment is committed to check +// and apply any eligible discount campaigns to the booking. +// Skips if the booking already has a completed non-discount payment — this +// prevents applying new discounts after a customer has already paid, which +// would create a credit balance or require a refund. +func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, userID string) { + var existingPayment int + db.DB.QueryRow(ctx, ` + SELECT COUNT(*) FROM payments + WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') + `, bookingID).Scan(&existingPayment) + // Only block if this is the 2nd+ payment — the first payment should still + // trigger discount application. Subsequent payments should not add new discounts. + if existingPayment >= 2 { + return + } + + var bookingTotal float64 + if err := db.DB.QueryRow(ctx, ` + SELECT COALESCE(SUM(price_val), 0) FROM ( + SELECT COALESCE(bs.override_price, s.price) AS price_val + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + UNION ALL + SELECT COALESCE(bcs.override_price, cs.price) + FROM booking_custom_services bcs + JOIN custom_services cs ON bcs.custom_service_id = cs.id + WHERE bcs.booking_id = $1 + ) sub + `, bookingID).Scan(&bookingTotal); err != nil { + log.Printf("Failed to calculate booking total for campaign check: %v", err) + return + } + if bookingTotal <= 0 { + return + } + + var campaignID string + var campaignPercent float64 + if err := db.DB.QueryRow(ctx, ` + SELECT id, discount_percent FROM discount_campaigns + WHERE status = 'active' AND campaign_type = 'time_based' + AND start_date <= NOW() AND end_date >= NOW() + AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) + ORDER BY discount_percent DESC LIMIT 1 + `).Scan(&campaignID, &campaignPercent); err == nil && campaignID != "" { + var exists int + db.DB.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, campaignID).Scan(&exists) + if exists == 0 { + discountAmount := roundTo2(bookingTotal * campaignPercent / 100) + if _, err := db.DB.Exec(ctx, ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6) + `, bookingID, userID, campaignID, campaignPercent, bookingTotal, discountAmount); err != nil { + log.Printf("Failed to insert time-based campaign discount: %v", err) + } else { + db.DB.Exec(ctx, ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) + VALUES ($1, 'partial', 'discount', $2, 'completed', $3) + `, bookingID, discountAmount, userID) + db.DB.Exec(ctx, ` + UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 + `, campaignID) + } + } + } + + var userBookingCount int + db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&userBookingCount) + + var milestoneCampaignID string + var milestonePercent float64 + db.DB.QueryRow(ctx, ` + SELECT id, discount_percent FROM discount_campaigns + WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count' + AND milestone_value = $1 + AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $2 AND source_id = discount_campaigns.id) + `, userBookingCount, userID).Scan(&milestoneCampaignID, &milestonePercent) + + if milestoneCampaignID != "" { + var exists int + db.DB.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, milestoneCampaignID).Scan(&exists) + if exists == 0 { + discountAmount := roundTo2(bookingTotal * milestonePercent / 100) + if _, err := db.DB.Exec(ctx, ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6) + `, bookingID, userID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount); err != nil { + log.Printf("Failed to insert per-user milestone discount: %v", err) + } else { + db.DB.Exec(ctx, ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) + VALUES ($1, 'partial', 'discount', $2, 'completed', $3) + `, bookingID, discountAmount, userID) + db.DB.Exec(ctx, ` + UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 + `, milestoneCampaignID) + } + } + } + + var firstVisitDate time.Time + db.DB.QueryRow(ctx, `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&firstVisitDate) + if !firstVisitDate.IsZero() { + annRows, err := db.DB.Query(ctx, ` + SELECT id, discount_percent, milestone_value, milestone_unit FROM discount_campaigns + WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'anniversary' + AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary') + `, userID) + if err == nil { + type annCampaign struct { + id string + pct float64 + value int + unit string + } + var campaigns []annCampaign + for annRows.Next() { + var c annCampaign + if annRows.Scan(&c.id, &c.pct, &c.value, &c.unit) == nil { + campaigns = append(campaigns, c) + } + } + annRows.Close() + + for _, c := range campaigns { + var exists int + db.DB.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, c.id).Scan(&exists) + if exists > 0 { + continue + } + + var matches bool + elapsed := time.Since(firstVisitDate) + switch c.unit { + case "months": + months := int(elapsed.Hours() / (30 * 24)) + matches = months >= c.value + case "years": + years := int(elapsed.Hours() / (365.25 * 24)) + matches = years >= c.value + } + if matches { + discountAmount := roundTo2(bookingTotal * c.pct / 100) + if _, err := db.DB.Exec(ctx, ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6) + `, bookingID, userID, c.id, c.pct, bookingTotal, discountAmount); err != nil { + log.Printf("Failed to insert anniversary discount: %v", err) + } else { + db.DB.Exec(ctx, ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) + VALUES ($1, 'partial', 'discount', $2, 'completed', $3) + `, bookingID, discountAmount, userID) + db.DB.Exec(ctx, ` + UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 + `, c.id) + } + break + } + } + } else { + log.Printf("Failed to query anniversary campaigns: %v", err) + } + } + + var firstPaymentMethod string + if err := db.DB.QueryRow(ctx, ` + SELECT payment_method FROM payments WHERE booking_id = $1 AND payment_method NOT IN ('discount', 'on_the_house') ORDER BY created_at ASC LIMIT 1 + `, bookingID).Scan(&firstPaymentMethod); err == nil && firstPaymentMethod == "in_person_card" { + var globalCount int + db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount) + + var globalCampaignID string + var globalPercent float64 + db.DB.QueryRow(ctx, ` + SELECT id, discount_percent FROM discount_campaigns + WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count' + AND milestone_value <= $1 + AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) + AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE source_id = discount_campaigns.id AND booking_id = $2) + ORDER BY milestone_value DESC LIMIT 1 + `, globalCount, bookingID).Scan(&globalCampaignID, &globalPercent) + + if globalCampaignID != "" { + var exists int + db.DB.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, globalCampaignID).Scan(&exists) + if exists == 0 { + discountAmount := roundTo2(bookingTotal * globalPercent / 100) + if _, err := db.DB.Exec(ctx, ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6) + `, bookingID, userID, globalCampaignID, globalPercent, bookingTotal, discountAmount); err != nil { + log.Printf("Failed to insert global milestone discount: %v", err) + } else { + db.DB.Exec(ctx, ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) + VALUES ($1, 'partial', 'discount', $2, 'completed', $3) + `, bookingID, discountAmount, userID) + db.DB.Exec(ctx, ` + UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 + `, globalCampaignID) + } + } + } + } +} + +// buildSplitRecords determines whether to split a single Square charge into +// multiple payment records. Before the booking start time, the first 50% of +// the total is recorded as 'deposit' (protected under the deposit policy) and +// +// The first 50% of the booking total (minus any already deposited) is always +// carved out as a 'deposit' record, regardless of the payment size. The +// remainder first covers the booking balance then overflows into a 'tip' record. +// +// The primary record carries the Square payment ID for refund routing; split +// records share the same SquarePaymentID so the refund loop can avoid duplicate +// Square API calls while still creating audit records. +func buildSplitRecords(primary PaymentRecord, reqPaymentType string, info *BookingPaymentInfo, paymentAmount float64) []PaymentRecord { + // After the booking starts there is no deposit protection window — + // record the payment as a single entry with its original type. + if time.Now().After(info.StartTime) { + return []PaymentRecord{primary} + } + + // 1. Deposit portion: up to 50% of total, minus what's already been paid. + maxDeposit := info.TotalAmount * ProtectedDepositMaxPct + remainingDepositRoom := math.Max(0, maxDeposit-info.TotalPaid) + depositAmount := math.Min(paymentAmount, remainingDepositRoom) + depositAmount = math.Round(depositAmount*100) / 100 + + // 2. Remaining after deposit. + remainingAfterDeposit := math.Round((paymentAmount-depositAmount)*100) / 100 + + // 3. Balance portion: covers whatever is still owed on the booking. + bookingRemaining := math.Max(0, info.TotalAmount-info.TotalPaid-depositAmount) + balancePortion := math.Min(remainingAfterDeposit, bookingRemaining) + balancePortion = math.Round(balancePortion*100) / 100 + + // 4. Tip: anything beyond the booking total. + tipPortion := math.Round((remainingAfterDeposit-balancePortion)*100) / 100 + + var records []PaymentRecord + splitIdx := 0 + + // 1. Deposit portion (always present when there's deposit room left). + if depositAmount > 0.004 { + dep := primary + dep.PaymentType = "deposit" + dep.Amount = depositAmount + records = append(records, dep) + splitIdx++ + } + + // 2. Balance / partial / full record — covers the remaining booking total. + if balancePortion > 0.004 { + bal := primary + bal.Amount = balancePortion + bal.Fees = 0 + if primary.IdempotencyKey != nil { + k := *primary.IdempotencyKey + fmt.Sprintf("-split-%d", splitIdx) + bal.IdempotencyKey = &k + } + totalPaidAfterBalance := info.TotalPaid + depositAmount + balancePortion + switch { + case totalPaidAfterBalance >= info.TotalAmount && totalPaidAfterBalance-balancePortion > 0: + bal.PaymentType = "balance" + case totalPaidAfterBalance >= info.TotalAmount: + bal.PaymentType = "full" + default: + bal.PaymentType = "partial" + } + records = append(records, bal) + splitIdx++ + } + + // If neither deposit nor balance was created (deposit exhausted, booking + // fully paid), the primary is still a valid record — use it directly. + if len(records) == 0 { + primary.Fees = 0 + records = append(records, primary) + } + + // Tip record — overflow beyond the booking total. + if tipPortion > 0.004 { + tip := primary + tip.PaymentType = "tip" + tip.Amount = tipPortion + tip.Fees = 0 + splitIdx++ + if primary.IdempotencyKey != nil { + k := *primary.IdempotencyKey + fmt.Sprintf("-split-%d", splitIdx) + tip.IdempotencyKey = &k + } + records = append(records, tip) + } + + // If nothing was appended (shouldn't happen given validation upstream), + // return the primary as a fallback. + if len(records) == 0 { + return []PaymentRecord{primary} + } + return records +} + +// nonDepositPaymentType picks the right label for the non-deposit portion of a +// split payment, following the same rules as the frontend's handlePayFull: +// 'balance' when some payment already exists, 'full' when covering everything, +// 'partial' when leaving a remainder. +func nonDepositPaymentType(reqType string, totalPaidAfterThis float64, thisPortion float64, bookingTotal float64) string { + if totalPaidAfterThis >= bookingTotal { + if totalPaidAfterThis-thisPortion > 0 { + return "balance" + } + return "full" + } + if reqType == "full" || reqType == "deposit" { + return "partial" + } + return "partial" +} + func GetUserPaymentMethods(w http.ResponseWriter, r *http.Request) { userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { @@ -632,9 +1331,9 @@ func DeletePaymentMethod(w http.ResponseWriter, r *http.Request) { } type CreatePaymentMethodRequest struct { - CardNumber string `json:"card_number"` - Expiry string `json:"expiry"` - CVC string `json:"cvc"` + CardNumber string `json:"card_number" validate:"required"` + Expiry string `json:"expiry" validate:"required"` + CVC string `json:"cvc" validate:"required"` } func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) { @@ -650,6 +1349,14 @@ func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) { return } + if err := validators.Validate.Struct(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // M8 + // L5 + if req.CardNumber == "" || req.Expiry == "" || req.CVC == "" { http.Error(w, "Card number, expiry, and CVC are required", http.StatusBadRequest) return @@ -769,13 +1476,6 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) { return } - if payment.PaymentType == "deposit" && (req.Amount+alreadyRefunded) >= int64(payment.Amount*100) { - err = service.UpdateBookingDepositPaid(r.Context(), payment.BookingID, false) - if err != nil { - log.Printf("Failed to update deposit paid: %v", err) - } - } - w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(RefundResponse{ ID: refundID, @@ -807,6 +1507,14 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) { return } + if err := validators.Validate.Struct(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // M8 + // L5 + if err := ValidateAmount(req.Amount); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -972,3 +1680,103 @@ func GetBookingPaymentSummary(w http.ResponseWriter, r *http.Request) { Refunds: refunds, }) } + +// PaymentLockDuration is the TTL for a payment-in-flight lock in minutes. +const PaymentLockDuration = 5 + +// AcquirePaymentLock creates or extends a 5-minute time_blocker for the +// booking's slot so that pending_release eviction is blocked during card +// entry and Square charge processing. +func AcquirePaymentLock(w http.ResponseWriter, r *http.Request) { + bookingID := chi.URLParam(r, "id") + if bookingID == "" || !validators.IsValidID(bookingID) { + http.Error(w, "Booking not found", http.StatusNotFound) + return + } + + userID, ok := r.Context().Value(mw.UserIDKey).(string) + if !ok || userID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + // Verify the user owns this booking. + var bookingUserID string + if err := db.DB.QueryRow(r.Context(), + "SELECT user_id FROM bookings WHERE id = $1", bookingID, + ).Scan(&bookingUserID); err != nil { + http.Error(w, "Booking not found", http.StatusNotFound) + return + } + if bookingUserID != userID { + http.Error(w, "Unauthorized", http.StatusForbidden) + return + } + + // Before acquiring the lock, double-check the slot is still available. + // For confirmed/in_progress bookings this is a formality; for + // pending_release bookings it catches the eviction race before we + // create a time_blocker — the NOT EXISTS guard in eviction queries + // handles the sub-5-minute race, this catches the >5-minute gap. + var currentStatus string + var startTime time.Time + if err := db.DB.QueryRow(r.Context(), + "SELECT status, start_time FROM bookings WHERE id = $1", bookingID, + ).Scan(¤tStatus, &startTime); err != nil { + http.Error(w, "Booking not found", http.StatusNotFound) + return + } + + // If the booking has been evicted (deposit_lapsed) or reached a terminal + // state, reject the lock — payment cannot proceed. + if !IsValidBookingStatusForPayment(currentStatus) || currentStatus == "pending" { + log.Printf("Payment lock rejected: booking %s is in status %q (no longer accepting payments)", bookingID, currentStatus) + http.Error(w, "This booking is no longer accepting payments. The slot may have been released.", http.StatusConflict) + return + } + + // Upsert the time_blocker: delete any existing PAYMENT_IN_FLIGHT for this + // booking, then insert a fresh one. This effectively extends the lock. + if _, err := db.DB.Exec(r.Context(), ` + DELETE FROM time_blockers + WHERE description = 'PAYMENT_IN_FLIGHT:' || $1 + `, bookingID); err != nil { + log.Printf("Failed to clear previous payment lock for booking %s: %v", bookingID, err) + } + + if _, err := db.DB.Exec(r.Context(), ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES (NOW(), $1, $2, $3) + `, PaymentLockDuration, "PAYMENT_IN_FLIGHT:"+bookingID, userID); err != nil { + log.Printf("Failed to acquire payment lock for booking %s: %v", bookingID, err) + http.Error(w, "Failed to secure payment slot", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "locked", + "ttl_min": PaymentLockDuration, + "bookingID": bookingID, + }) +} + +// ReleasePaymentLock removes the PAYMENT_IN_FLIGHT time_blocker for a booking. +func ReleasePaymentLock(w http.ResponseWriter, r *http.Request) { + bookingID := chi.URLParam(r, "id") + if bookingID == "" || !validators.IsValidID(bookingID) { + http.Error(w, "Booking not found", http.StatusNotFound) + return + } + + if _, err := db.DB.Exec(r.Context(), ` + DELETE FROM time_blockers + WHERE description = 'PAYMENT_IN_FLIGHT:' || $1 + `, bookingID); err != nil { + log.Printf("Failed to release payment lock for booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/backend/handlers/payments/payments_test.go b/backend/handlers/payments/payments_test.go index 2f77b18..d4978a2 100644 --- a/backend/handlers/payments/payments_test.go +++ b/backend/handlers/payments/payments_test.go @@ -13,6 +13,7 @@ import ( "net/http/httptest" "os" "testing" + "time" "crussell/db" "crussell/internal/square" @@ -224,6 +225,17 @@ func TestTerminalPayment_HappyPath(t *testing.T) { } func setupTestData(t *testing.T) (string, string, string) { + return setupTestDataAtTime(t, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) +} + +// setupTestDataPast creates a booking with start_time in the past (1 hour ago) +// to prevent payment-split logic from triggering. Used by tests that verify +// payment sequencing or idempotency rather than deposit allocation. +func setupTestDataPast(t *testing.T) (string, string, string) { + return setupTestDataAtTime(t, time.Now().Add(-1*time.Hour)) +} + +func setupTestDataAtTime(t *testing.T, startTime time.Time) (string, string, string) { userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) @@ -234,7 +246,7 @@ func setupTestData(t *testing.T) (string, string, string) { t.Fatalf("failed to create test service: %v", err) } - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, startTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } @@ -742,7 +754,7 @@ func TestTipPayment_NoPriorPayment(t *testing.T) { func TestIdempotency_SameKeyReturnsExisting(t *testing.T) { resetTestData(t) - userID, bookingID, _ := setupTestData(t) + userID, bookingID, _ := setupTestDataPast(t) userToken := jwt.GenerateUserToken(userID) @@ -803,7 +815,7 @@ func TestIdempotency_SameKeyReturnsExisting(t *testing.T) { func TestIdempotency_DifferentKeyCreatesNew(t *testing.T) { resetTestData(t) - userID, bookingID, _ := setupTestData(t) + userID, bookingID, _ := setupTestDataPast(t) userToken := jwt.GenerateUserToken(userID) @@ -832,8 +844,11 @@ func TestIdempotency_DifferentKeyCreatesNew(t *testing.T) { w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken) - if w2.Code != http.StatusOK { - t.Errorf("second request expected status 200, got %d. body: %s", w2.Code, w2.Body.String()) + // The second request is blocked because only one "full" payment is + // allowed per booking (the payment-type duplicate guard prevents the + // two-tab double-payment race even when idempotency keys differ). + if w2.Code != http.StatusConflict { + t.Errorf("second request expected status 409, got %d. body: %s", w2.Code, w2.Body.String()) } var count int @@ -841,8 +856,166 @@ func TestIdempotency_DifferentKeyCreatesNew(t *testing.T) { if err != nil { t.Errorf("failed to query payments: %v", err) } - if count != 2 { - t.Errorf("expected 2 payments (different keys), got %d", count) + if count != 1 { + t.Errorf("expected 1 payment (second was blocked), got %d", count) + } +} + +// ============================================================================= +// Payment-type duplicate guard — serialization lock prevents double payments +// ============================================================================= + +func TestCreateBookingPayment_DifferentPaymentTypesAllowed(t *testing.T) { + resetTestData(t) + + _, bookingID, userToken := setupPaymentStatusTest(t, "pending_release") + + // First: a deposit payment should succeed. + cardToken := "cnon:diff-type-card" + depositReq := CreateBookingPaymentRequest{ + Amount: 2000, + PaymentType: "deposit", + NewCardToken: &cardToken, + IdempotencyKey: "diff-type-deposit-" + bookingID, + } + + handler := CreateBookingPayment + w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", depositReq, userToken) + if w1.Code != http.StatusOK { + t.Fatalf("deposit payment expected 200, got %d. body: %s", w1.Code, w1.Body.String()) + } + + // Second: a balance payment uses a different payment_type — should also succeed. + balanceReq := CreateBookingPaymentRequest{ + Amount: 3000, + PaymentType: "balance", + NewCardToken: &cardToken, + IdempotencyKey: "diff-type-balance-" + bookingID, + } + + w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", balanceReq, userToken) + if w2.Code != http.StatusOK { + t.Errorf("balance payment expected 200 (different type allowed), got %d. body: %s", w2.Code, w2.Body.String()) + } + + // Verify at least one payment of each type exists. buildSplitRecords may + // create extra records (e.g. a 'balance' portion alongside 'deposit'), so + // we check DISTINCT types rather than a raw row count. + var distinctTypes []string + rows, err := db.DB.Query(context.Background(), + "SELECT DISTINCT payment_type FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') ORDER BY payment_type", + bookingID) + if err != nil { + t.Fatalf("failed to query payments: %v", err) + } + defer rows.Close() + for rows.Next() { + var pt string + if err := rows.Scan(&pt); err == nil { + distinctTypes = append(distinctTypes, pt) + } + } + if len(distinctTypes) < 2 { + t.Errorf("expected at least 2 distinct payment types, got %d: %v", len(distinctTypes), distinctTypes) + } +} + +func TestCreateBookingPayment_DuplicateTypeBlocked(t *testing.T) { + resetTestData(t) + + _, bookingID, userToken := setupPaymentStatusTest(t, "pending_release") + + cardToken := "cnon:dup-type-card" + + // First 'full' payment succeeds. + req1 := CreateBookingPaymentRequest{ + Amount: 5000, + PaymentType: "full", + NewCardToken: &cardToken, + IdempotencyKey: "dup-type-first-" + bookingID, + } + + handler := CreateBookingPayment + w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken) + if w1.Code != http.StatusOK { + t.Fatalf("first payment expected 200, got %d. body: %s", w1.Code, w1.Body.String()) + } + + // Second 'full' payment with a different idempotency key should be blocked + // by the payment-type duplicate guard. + req2 := CreateBookingPaymentRequest{ + Amount: 2000, + PaymentType: "full", + NewCardToken: &cardToken, + IdempotencyKey: "dup-type-second-" + bookingID, + } + + w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken) + if w2.Code != http.StatusConflict { + t.Errorf("duplicate 'full' payment expected 409, got %d. body: %s", w2.Code, w2.Body.String()) + } + + // Verify only one real payment was created. buildSplitRecords converts the + // first 'full' payment into 'deposit' + 'balance', so we count deposit records + // rather than 'full' — the exact guard above confirmed the 409 rejection. + var depositCount int + err := db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'deposit' AND payment_method NOT IN ('discount', 'on_the_house')", + bookingID).Scan(&depositCount) + if err != nil { + t.Fatalf("failed to count payments: %v", err) + } + if depositCount != 1 { + t.Errorf("expected 1 deposit record (split from first 'full' payment), got %d", depositCount) + } +} + +func TestCreateBookingPayment_MultiplePartialAllowed(t *testing.T) { + resetTestData(t) + + _, bookingID, userToken := setupPaymentStatusTest(t, "confirmed") + + cardToken := "cnon:partial-card" + + // First partial payment. + req1 := CreateBookingPaymentRequest{ + Amount: 1000, + PaymentType: "partial", + NewCardToken: &cardToken, + IdempotencyKey: "partial-first-" + bookingID, + } + + handler := CreateBookingPayment + w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken) + if w1.Code != http.StatusOK { + t.Fatalf("first partial expected 200, got %d. body: %s", w1.Code, w1.Body.String()) + } + + // Second partial payment (different key, same type) — allowed because + // the duplicate guard explicitly exempts 'partial'. + req2 := CreateBookingPaymentRequest{ + Amount: 1500, + PaymentType: "partial", + NewCardToken: &cardToken, + IdempotencyKey: "partial-second-" + bookingID, + } + + w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken) + if w2.Code != http.StatusOK { + t.Errorf("second partial expected 200, got %d. body: %s", w2.Code, w2.Body.String()) + } + + // Count all real payments (buildSplitRecords converts partials to deposit + // when within the 50% deposit cap). Both should have been created. + var total int + err := db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house')", + bookingID).Scan(&total) + if err != nil { + t.Fatalf("failed to count payments: %v", err) + } + if total != 2 { + t.Errorf("expected 2 payments (both created), got %d", total) } } @@ -864,6 +1037,17 @@ func TestSquareWebhook_DevMode_NoSignature(t *testing.T) { // ============================================================ func setupDepositBooking(t *testing.T) (string, string) { + return setupDepositBookingAtTime(t, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) +} + +// setupDepositBookingPast creates a confirmed booking with start_time in the past +// (1 hour ago). This prevents the payment-split logic from triggering, which is +// useful for tests that verify payment sequencing rather than deposit splitting. +func setupDepositBookingPast(t *testing.T) (string, string) { + return setupDepositBookingAtTime(t, time.Now().Add(-1*time.Hour)) +} + +func setupDepositBookingAtTime(t *testing.T, startTime time.Time) (string, string) { userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) @@ -874,7 +1058,7 @@ func setupDepositBooking(t *testing.T) (string, string) { t.Fatalf("failed to create test service: %v", err) } - bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, startTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } @@ -1039,6 +1223,443 @@ func TestBookingPayment_BalancePayment(t *testing.T) { } } +// --------------------------------------------------------------------------- +// Payment-split tests — verify that a single Square charge is recorded as +// multiple payment rows when paid before the booking start time, and that +// both records share the same square_payment_id. +// --------------------------------------------------------------------------- + +func TestBookingPayment_FullPayment_SplitsIntoDepositAndBalance(t *testing.T) { + // A full payment of £50 on a £50 booking (future-dated) should be split: + // record 1: payment_type='deposit', amount=25.00 + // record 2: payment_type='balance', amount=25.00 + resetTestData(t) + + userID, bookingID := setupDepositBooking(t) + userToken := jwt.GenerateUserToken(userID) + + cardToken := "cnon:split-full-card" + req := CreateBookingPaymentRequest{ + Amount: 5000, + PaymentType: "full", + NewCardToken: &cardToken, + IdempotencyKey: "split-full-test-1", + } + + handler := CreateBookingPayment + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + // Should be exactly 2 payment records. + rows, err := db.DB.Query(context.Background(), + `SELECT payment_type, amount, square_payment_id + FROM payments WHERE booking_id = $1 ORDER BY amount DESC`, bookingID) + if err != nil { + t.Fatalf("failed to query payments: %v", err) + } + defer rows.Close() + + var records []struct { + ptype string + amount float64 + squarePaymentID *string + } + for rows.Next() { + var r struct { + ptype string + amount float64 + squarePaymentID *string + } + if err := rows.Scan(&r.ptype, &r.amount, &r.squarePaymentID); err != nil { + t.Fatalf("failed to scan row: %v", err) + } + records = append(records, r) + } + + if len(records) != 2 { + t.Fatalf("expected 2 split records, got %d", len(records)) + } + + // First record should be the deposit portion (larger or equal — deposit is 25, balance is 25). + if records[0].ptype != "deposit" { + t.Errorf("expected first record to be 'deposit', got %q", records[0].ptype) + } + // Second record should be balance. + if records[1].ptype != "balance" { + t.Errorf("expected second record to be 'balance', got %q", records[1].ptype) + } + + // Both records must share the same square_payment_id. + if records[0].squarePaymentID == nil || records[1].squarePaymentID == nil { + t.Error("both records should have a square_payment_id") + } else if *records[0].squarePaymentID != *records[1].squarePaymentID { + t.Errorf("expected same square_payment_id, got %q and %q", + *records[0].squarePaymentID, *records[1].squarePaymentID) + } +} + +func TestBookingPayment_FullPayment_PastBooking_DoesNotSplit(t *testing.T) { + // A full payment on a PAST booking should NOT split (single record). + resetTestData(t) + + userID, bookingID := setupDepositBookingPast(t) + userToken := jwt.GenerateUserToken(userID) + + cardToken := "cnon:nosplit-card" + req := CreateBookingPaymentRequest{ + Amount: 5000, + PaymentType: "full", + NewCardToken: &cardToken, + IdempotencyKey: "nosplit-1", + } + + handler := CreateBookingPayment + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var count int + err := db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count) + if err != nil { + t.Fatalf("failed to query payments: %v", err) + } + if count != 1 { + t.Errorf("expected 1 payment (no-split), got %d", count) + } +} + +func TestBookingPayment_TransactionAtomicity_SplitRollsBackOnError(t *testing.T) { + // Verify that when the split-record insert fails, the entire group rolls + // back atomically. We simulate a failure by causing the second INSERT to + // violate a NOT NULL constraint (passing an invalid record). + resetTestData(t) + + _, bookingID := setupDepositBooking(t) + + // Use a nil idempotency key on the split record — this works fine for both. + // Instead we rely on the fact that the handler wraps both inserts in a + // single transaction: if either fails, neither survives. + // + // Because we can't easily inject a DB error through the handler, we verify + // the architecture at the service level instead: + + ctx := context.Background() + tx, err := db.DB.Begin(ctx) + if err != nil { + t.Fatalf("failed to begin tx: %v", err) + } + defer tx.Rollback(ctx) + + svc := NewPaymentService() + + now := time.Now() + // First record — valid. + pid1, err := svc.CreatePaymentRecordTx(ctx, tx, PaymentRecord{ + BookingID: bookingID, + PaymentType: "deposit", + PaymentMethod: "cash", + Status: "completed", + Amount: 25.00, + CreatedAt: now, + UpdatedAt: now, + }, nil) + if err != nil { + t.Fatalf("failed to create first payment record: %v", err) + } + if pid1 == "" { + t.Fatal("expected non-empty payment id") + } + + // Second record — also valid. + pid2, err := svc.CreatePaymentRecordTx(ctx, tx, PaymentRecord{ + BookingID: bookingID, + PaymentType: "balance", + PaymentMethod: "cash", + Status: "completed", + Amount: 25.00, + CreatedAt: now, + UpdatedAt: now, + }, nil) + if err != nil { + t.Fatalf("failed to create second payment record: %v", err) + } + if pid2 == "" { + t.Fatal("expected non-empty payment id") + } + + if err := tx.Commit(ctx); err != nil { + t.Fatalf("failed to commit tx: %v", err) + } + + // Both records should exist. + var count int + db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1 OR id = $2", pid1, pid2).Scan(&count) + if count != 2 { + t.Errorf("expected 2 committed records, got %d", count) + } + + // Now test rollback: start a new tx, insert, then rollback. + tx2, err := db.DB.Begin(ctx) + if err != nil { + t.Fatalf("failed to begin tx2: %v", err) + } + + pid3, err := svc.CreatePaymentRecordTx(ctx, tx2, PaymentRecord{ + BookingID: bookingID, + PaymentType: "deposit", + PaymentMethod: "cash", + Status: "completed", + Amount: 10.00, + CreatedAt: now, + UpdatedAt: now, + }, nil) + if err != nil { + t.Fatalf("failed to create rolled-back record: %v", err) + } + + tx2.Rollback(ctx) + + // Rolled-back record should NOT exist. + var rollbackCount int + db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", pid3).Scan(&rollbackCount) + if rollbackCount != 0 { + t.Errorf("expected 0 records after rollback, got %d", rollbackCount) + } +} + +// --------------------------------------------------------------------------- +// nonDepositPaymentType unit tests — pure function, no DB needed. +// --------------------------------------------------------------------------- + +func TestNonDepositPaymentType_FirstPaymentFull(t *testing.T) { + // First-ever payment, paying full amount → "full" + result := nonDepositPaymentType("full", 100, 100, 100) + if result != "full" { + t.Errorf("expected 'full', got %q", result) + } +} + +func TestNonDepositPaymentType_BalanceWhenPriorExists(t *testing.T) { + // Total paid after this = 100, portion = 50, prior = 50 → "balance" + result := nonDepositPaymentType("balance", 100, 50, 100) + if result != "balance" { + t.Errorf("expected 'balance', got %q", result) + } +} + +func TestNonDepositPaymentType_PartialWhenUnderTotal(t *testing.T) { + // Paying 30 on a 100 total → "partial" + result := nonDepositPaymentType("partial", 30, 30, 100) + if result != "partial" { + t.Errorf("expected 'partial', got %q", result) + } + + // Same result when request type is "full" but amount doesn't cover total + result = nonDepositPaymentType("full", 80, 80, 100) + if result != "partial" { + t.Errorf("expected 'partial' when full doesn't cover total, got %q", result) + } +} + +func TestNonDepositPaymentType_FullWhenFirstPaymentFullyCovers(t *testing.T) { + // First payment ever, exactly covers total → "full" + result := nonDepositPaymentType("full", 100, 100, 100) + if result != "full" { + t.Errorf("expected 'full', got %q", result) + } +} + +func TestNonDepositPaymentType_DepositReqTypeBecomesPartial(t *testing.T) { + // Request type is "deposit" but amount doesn't fully cover → "partial" + result := nonDepositPaymentType("deposit", 30, 30, 100) + if result != "partial" { + t.Errorf("expected 'partial' for deposit type under total, got %q", result) + } +} + +// --------------------------------------------------------------------------- +// buildSplitRecords unit tests — pure function, no DB needed. +// --------------------------------------------------------------------------- + +func makeTestRecord(bookingID, ptype string, amount float64) PaymentRecord { + now := time.Now() + key := "test-key" + return PaymentRecord{ + BookingID: bookingID, + PaymentType: ptype, + PaymentMethod: "online_square", + Status: "completed", + Amount: amount, + SquarePaymentID: strPtr("sq_test"), + IdempotencyKey: &key, + Fees: 1.50, + CreatedAt: now, + UpdatedAt: now, + } +} + +func strPtr(s string) *string { return &s } + +func TestBuildSplitRecords_FutureBooking_FullPayment_Splits(t *testing.T) { + // £50 payment on a £50 future booking → splits into deposit £25 + balance £25 + record := makeTestRecord("b1", "full", 50) + info := &BookingPaymentInfo{ + StartTime: time.Now().Add(48 * time.Hour), + TotalAmount: 50, + TotalPaid: 0, + } + records := buildSplitRecords(record, "full", info, 50) + + if len(records) != 2 { + t.Fatalf("expected 2 records, got %d", len(records)) + } + if records[0].PaymentType != "deposit" { + t.Errorf("expected first record 'deposit', got %q", records[0].PaymentType) + } + if records[0].Amount != 25 { + t.Errorf("expected first record amount 25, got %.2f", records[0].Amount) + } + if records[1].PaymentType != "balance" { + t.Errorf("expected second record 'balance', got %q", records[1].PaymentType) + } + if records[1].Amount != 25 { + t.Errorf("expected second record amount 25, got %.2f", records[1].Amount) + } + // Both share the same SquarePaymentID. + if *records[0].SquarePaymentID != *records[1].SquarePaymentID { + t.Error("split records must share square_payment_id") + } + // Split record has separate idempotency key. + if *records[1].IdempotencyKey != *records[0].IdempotencyKey+"-split-1" { + t.Errorf("split key should be derived, got %q", *records[1].IdempotencyKey) + } + // Split record has zero fees (all on primary). + if records[1].Fees != 0 { + t.Errorf("expected split fees=0, got %.2f", records[1].Fees) + } +} + +func TestBuildSplitRecords_PastBooking_NoSplit(t *testing.T) { + // Same amount on a PAST booking → single record + record := makeTestRecord("b2", "full", 50) + info := &BookingPaymentInfo{ + StartTime: time.Now().Add(-2 * time.Hour), + TotalAmount: 50, + TotalPaid: 0, + } + records := buildSplitRecords(record, "full", info, 50) + + if len(records) != 1 { + t.Fatalf("expected 1 record (no split), got %d", len(records)) + } + if records[0].PaymentType != "full" { + t.Errorf("expected 'full', got %q", records[0].PaymentType) + } +} + +func TestBuildSplitRecords_DepositWithinCap_NoSplit(t *testing.T) { + // £20 deposit on a £50 total (40% < 50% cap) → single deposit record + record := makeTestRecord("b3", "deposit", 20) + info := &BookingPaymentInfo{ + StartTime: time.Now().Add(48 * time.Hour), + TotalAmount: 50, + TotalPaid: 0, + } + records := buildSplitRecords(record, "deposit", info, 20) + + if len(records) != 1 { + t.Fatalf("expected 1 record (within cap), got %d", len(records)) + } + if records[0].PaymentType != "deposit" { + t.Errorf("expected 'deposit', got %q", records[0].PaymentType) + } +} + +func TestBuildSplitRecords_PaymentLessThanDepositMax_NoSplit(t *testing.T) { + // £25 on a £100 total (25% < 50% cap) → single deposit record + record := makeTestRecord("b4", "deposit", 25) + info := &BookingPaymentInfo{ + StartTime: time.Now().Add(48 * time.Hour), + TotalAmount: 100, + TotalPaid: 0, + } + records := buildSplitRecords(record, "deposit", info, 25) + + if len(records) != 1 { + t.Fatalf("expected 1 record (under 50%%), got %d", len(records)) + } +} + +// --------------------------------------------------------------------------- +// Handler-level atomicity — verify the full handler succeeds with split. +// --------------------------------------------------------------------------- + +func TestBookingPayment_HandlerAtomicity_SplitSucceeds(t *testing.T) { + resetTestData(t) + + userID, bookingID := setupDepositBooking(t) + userToken := jwt.GenerateUserToken(userID) + + cardToken := "cnon:atomic-card" + req := CreateBookingPaymentRequest{ + Amount: 5000, + PaymentType: "full", + NewCardToken: &cardToken, + IdempotencyKey: "atomic-test-1", + } + + handler := CreateBookingPayment + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp PaymentResponse + if err := parsePaymentResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if resp.ID == "" { + t.Fatal("expected non-empty payment ID") + } + if resp.Amount != 5000 { + t.Errorf("expected amount 5000, got %d", resp.Amount) + } + if resp.PaymentType != "full" { + t.Errorf("expected payment type 'full' in response, got %q", resp.PaymentType) + } + + // Verify both split records exist and the total paid is correct. + var recordCount int + db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'", bookingID).Scan(&recordCount) + if recordCount != 2 { + t.Errorf("expected 2 completed payment records from split, got %d", recordCount) + } + + var totalPaid float64 + db.DB.QueryRow(context.Background(), + "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed'", bookingID).Scan(&totalPaid) + if totalPaid != 50.00 { + t.Errorf("expected total paid £50.00, got £%.2f", totalPaid) + } + + // Deposit threshold should have been met — verify booking promoted from pending_release. + var status string + db.DB.QueryRow(context.Background(), + "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) + if status == "pending_release" { + t.Error("expected booking to be promoted from pending_release after payment meets 20% threshold") + } +} + func TestBookingPayment_ZeroAmountRejected(t *testing.T) { resetTestData(t) @@ -1174,7 +1795,8 @@ func TestBookingPayment_DepositFollowedByBalance(t *testing.T) { func TestBookingPayment_PartialFollowedByBalance(t *testing.T) { resetTestData(t) - userID, bookingID := setupDepositBooking(t) + // Past booking to avoid payment-split; we're testing sequence not deposit allocation. + userID, bookingID := setupDepositBookingPast(t) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:partial-balance-card" @@ -1348,10 +1970,10 @@ func TestDeletePaymentMethod_WrongOwnerRejected(t *testing.T) { func TestValidatePartialAmount(t *testing.T) { tests := []struct { - name string - amountCents int64 + name string + amountCents int64 remainingCents int64 - expectErr bool + expectErr bool }{ {"valid partial", 500, 1000, false}, {"exact remaining", 1000, 1000, false}, @@ -1402,11 +2024,11 @@ func TestGetBookingRemainingBalanceCents(t *testing.T) { } _, err = service.CreatePaymentRecord(context.Background(), PaymentRecord{ - BookingID: bookingID, - PaymentType: "partial", + BookingID: bookingID, + PaymentType: "partial", PaymentMethod: "cash", - Status: "completed", - Amount: 20.00, + Status: "completed", + Amount: 20.00, }, nil) if err != nil { t.Fatalf("failed to create payment: %v", err) @@ -1421,11 +2043,11 @@ func TestGetBookingRemainingBalanceCents(t *testing.T) { } _, err = service.CreatePaymentRecord(context.Background(), PaymentRecord{ - BookingID: bookingID, - PaymentType: "balance", + BookingID: bookingID, + PaymentType: "balance", PaymentMethod: "cash", - Status: "completed", - Amount: float64(afterPartial) / 100.0, + Status: "completed", + Amount: float64(afterPartial) / 100.0, }, nil) if err != nil { t.Fatalf("failed to create payment: %v", err) @@ -1632,4 +2254,4 @@ func TestCreatePaymentMethod_SecondCardNotDefault(t *testing.T) { if card.IsDefault { t.Error("expected second card to NOT be default") } -} \ No newline at end of file +} diff --git a/backend/handlers/payments/service.go b/backend/handlers/payments/service.go index ff7cc40..bb438a9 100644 --- a/backend/handlers/payments/service.go +++ b/backend/handlers/payments/service.go @@ -14,14 +14,14 @@ import ( ) type SavedCard struct { - ID string `json:"id"` + ID string `json:"id"` SquareCardID string `json:"square_card_id"` - Brand string `json:"brand"` - Last4 string `json:"last_4"` - ExpMonth int `json:"exp_month"` - ExpYear int `json:"exp_year"` - Fingerprint string `json:"fingerprint"` - IsDefault bool `json:"is_default"` + Brand string `json:"brand"` + Last4 string `json:"last_4"` + ExpMonth int `json:"exp_month"` + ExpYear int `json:"exp_year"` + Fingerprint string `json:"fingerprint"` + IsDefault bool `json:"is_default"` } type PaymentService struct{} @@ -31,39 +31,39 @@ func NewPaymentService() *PaymentService { } type PaymentRecord struct { - ID string - BookingID string - PaymentType string - PaymentMethod string - VendorCode *string - InvoiceNumber *int - Status string - Amount float64 - CardLast4 string - IsVATApplicable bool - VATRate *float64 - VATAmount *float64 - NetAmount *float64 - UserSavedCardID *string - SquarePaymentID *string - IdempotencyKey *string - Fees float64 - CreatedAt time.Time - UpdatedAt time.Time - CreatedBy *string - GiftCardID *string + ID string + BookingID string + PaymentType string + PaymentMethod string + VendorCode *string + InvoiceNumber *int + Status string + Amount float64 + CardLast4 string + IsVATApplicable bool + VATRate *float64 + VATAmount *float64 + NetAmount *float64 + UserSavedCardID *string + SquarePaymentID *string + IdempotencyKey *string + Fees float64 + CreatedAt time.Time + UpdatedAt time.Time + CreatedBy *string + GiftCardID *string } type RefundRecord struct { - ID string - PaymentID string - BookingID string - Amount float64 - SquareRefundID *string - Status string - Reason string - CreatedBy *string - CreatedAt time.Time + ID string + PaymentID string + BookingID string + Amount float64 + SquareRefundID *string + Status string + Reason string + CreatedBy *string + CreatedAt time.Time } type PaymentSummary struct { @@ -77,19 +77,39 @@ type PaymentSummary struct { func (s *PaymentService) CalculateFees(amount int64, method string) float64 { if method == "online" { - return float64((amount * 14 / 1000) + 25) / 100.0 + return float64((amount*14/1000)+25) / 100.0 } - return float64(amount * 175 / 10000) / 100.0 + return float64(amount*175/10000) / 100.0 } func (s *PaymentService) CreatePaymentRecord(ctx context.Context, record PaymentRecord, giftCardID *string) (string, error) { + return s.insertPaymentRecord(ctx, record, giftCardID, db.DB) +} + +// CreatePaymentRecordTx is identical to CreatePaymentRecord but accepts a +// pgx.Tx so the insert is part of an existing database transaction. This +// is used by CreateBookingPayment when inserting multiple split records +// from a single Square charge — wrapping both inserts in a transaction +// ensures atomicity (both succeed or both roll back). +func (s *PaymentService) CreatePaymentRecordTx(ctx context.Context, tx pgx.Tx, record PaymentRecord, giftCardID *string) (string, error) { + return s.insertPaymentRecord(ctx, record, giftCardID, tx) +} + +// insertPaymentRecord holds the common INSERT logic. The querier parameter +// accepts either *pgxpool.Pool or pgx.Tx so callers can choose transactional +// or non-transactional insertion. +type querier interface { + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row +} + +func (s *PaymentService) insertPaymentRecord(ctx context.Context, record PaymentRecord, giftCardID *string, q querier) (string, error) { var bookingID *string if record.BookingID != "" { bookingID = &record.BookingID } var id string - err := db.DB.QueryRow(ctx, ` + err := q.QueryRow(ctx, ` INSERT INTO payments ( booking_id, payment_type, payment_method, vendor_code, invoice_number, status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount, @@ -326,14 +346,6 @@ func (s *PaymentService) GetAlreadyRefundedAmount(ctx context.Context, paymentID return int64(amount * 100), nil } -func (s *PaymentService) UpdateBookingDepositPaid(ctx context.Context, bookingID string, depositPaid bool) error { - _, err := db.DB.Exec(ctx, ` - UPDATE bookings SET deposit_paid = $1 WHERE id = $2 - `, depositPaid, bookingID) - - return err -} - func (s *PaymentService) HasCompletedPayment(ctx context.Context, bookingID string) (bool, error) { var count int err := db.DB.QueryRow(ctx, ` @@ -355,6 +367,44 @@ func (s *PaymentService) GetBookingStatus(ctx context.Context, bookingID string) return status, nil } +// BookingPaymentInfo holds booking-level data needed for payment split decisions. +type BookingPaymentInfo struct { + StartTime time.Time + TotalAmount float64 + TotalPaid float64 + Status string +} + +// GetBookingPaymentInfo fetches the booking start time, total service amount, and +// total completed payments for a booking. +func (s *PaymentService) GetBookingPaymentInfo(ctx context.Context, bookingID string) (*BookingPaymentInfo, error) { + var info BookingPaymentInfo + err := db.DB.QueryRow(ctx, ` + SELECT b.start_time, b.status, + COALESCE(bt.total_amount, 0), + COALESCE(pt.total_paid, 0) + FROM bookings b + LEFT JOIN ( + SELECT booking_id, COALESCE(SUM(price_val), 0) AS total_amount FROM ( + SELECT bs.booking_id, COALESCE(bs.override_price, s.price) AS price_val + FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1 + UNION ALL + SELECT bcs.booking_id, COALESCE(bcs.override_price, cs.price) + FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = $1 + ) sub GROUP BY booking_id + ) bt ON b.id = bt.booking_id + LEFT JOIN ( + SELECT booking_id, SUM(amount) AS total_paid + FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') GROUP BY booking_id + ) pt ON b.id = pt.booking_id + WHERE b.id = $1 + `, bookingID).Scan(&info.StartTime, &info.Status, &info.TotalAmount, &info.TotalPaid) + if err != nil { + return nil, err + } + return &info, nil +} + func (s *PaymentService) GetBookingUserID(ctx context.Context, bookingID string) (string, error) { var userID string err := db.DB.QueryRow(ctx, `SELECT user_id FROM bookings WHERE id = $1`, bookingID).Scan(&userID) @@ -515,4 +565,4 @@ func (s *PaymentService) GetCardByID(ctx context.Context, cardID, userID string) return &c, nil } -var SquareClient square.SquareClient \ No newline at end of file +var SquareClient square.SquareClient diff --git a/backend/handlers/payments/till.go b/backend/handlers/payments/till.go index ea704f1..27ad733 100644 --- a/backend/handlers/payments/till.go +++ b/backend/handlers/payments/till.go @@ -18,11 +18,11 @@ import ( ) type TillSaleRequest struct { - ItemType string `json:"item_type"` - Action string `json:"action"` - Amount float64 `json:"amount"` + 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"` + 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"` @@ -53,6 +53,14 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { 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 diff --git a/backend/handlers/payments/validators.go b/backend/handlers/payments/validators.go index 5919a9b..ee6b273 100644 --- a/backend/handlers/payments/validators.go +++ b/backend/handlers/payments/validators.go @@ -58,4 +58,4 @@ func ValidateCardInfo(cardID, newCardToken *string) error { return errors.New("either card_id or new_card_token is required") } return nil -} \ No newline at end of file +}