package payments import ( "context" "log" "math" "time" "crussell/db" "crussell/internal/square" ) type RefundCalculationResult struct { TotalPrePaid float64 `json:"total_pre_paid"` ProtectedDeposit float64 `json:"protected_deposit"` RefundableAmount float64 `json:"refundable_amount"` KeptAmount float64 `json:"kept_amount"` HoursUntilAppointment float64 `json:"hours_until_appointment"` Tier string `json:"tier"` } // CalculateRefundForCancellation computes the refund amounts for a cancelled booking. // // Track A (universal — same rules for all bookings): // - >72 hours notice: Full refund of all pre-payments // - 24-72 hours notice: Keep protected deposit (up to 50%), refund the rest // - <24 hours or no-show: Keep all pre-payments // // The "protected deposit" is defined as min(totalPrePaid, subtotal * 0.50). // This means up to 50% of the subtotal is always treated as a deposit for // refund purposes, regardless of whether deposit_required was set on the booking. func CalculateRefundForCancellation( subtotal float64, totalPrePaid float64, cancellationTime time.Time, startTime time.Time, ) RefundCalculationResult { hoursUntilAppointment := startTime.Sub(cancellationTime).Hours() protectedDeposit := math.Min(totalPrePaid, subtotal*ProtectedDepositMaxPct) // Round to 2 decimal places protectedDeposit = math.Round(protectedDeposit*100) / 100 totalPrePaidRounded := math.Round(totalPrePaid*100) / 100 var refundableAmount, keptAmount float64 var tier string fullHrs := FullRefundThreshold.Hours() partHrs := PartialRefundThreshold.Hours() switch { case hoursUntilAppointment > fullHrs: refundableAmount = totalPrePaidRounded keptAmount = 0 tier = FullRefundTier case hoursUntilAppointment >= partHrs: keptAmount = protectedDeposit refundableAmount = totalPrePaidRounded - keptAmount if refundableAmount < 0 { refundableAmount = 0 } tier = PartialRefundTier default: keptAmount = totalPrePaidRounded refundableAmount = 0 tier = NoRefundTier } return RefundCalculationResult{ TotalPrePaid: totalPrePaidRounded, ProtectedDeposit: protectedDeposit, RefundableAmount: refundableAmount, KeptAmount: keptAmount, HoursUntilAppointment: hoursUntilAppointment, Tier: tier, } } // ProcessCancellationRefund calculates and records refunds for a cancelled booking. // It processes refunds against completed payments on the booking up to the // calculated refundable amount, creating refund records in the database. // Returns the refund calculation and whether any refunds were processed. func ProcessCancellationRefund( ctx context.Context, bookingID string, subtotal float64, totalPrePaid float64, startTime time.Time, cancellationTime time.Time, reason string, actorID *string, ) (*RefundCalculationResult, error) { calc := CalculateRefundForCancellation(subtotal, totalPrePaid, cancellationTime, startTime) if calc.RefundableAmount <= 0 { return &calc, nil } // Get the booking's user info for refund routing. var bookingUserID string var isGuest bool if err := db.DB.QueryRow(ctx, ` SELECT b.user_id, COALESCE(u.account_role = 'guest', false) FROM bookings b LEFT JOIN users u ON b.user_id = u.id WHERE b.id = $1 `, bookingID).Scan(&bookingUserID, &isGuest); err != nil { log.Printf("Failed to get booking user info for refund: %v", err) // Non-fatal — we'll still process Square refunds but skip balance credits. } rows, err := db.DB.Query(ctx, ` SELECT id, amount, payment_method, square_payment_id, gift_card_id FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') ORDER BY created_at ASC `, bookingID) if err != nil { log.Printf("Failed to fetch payments for refund: %v", err) return &calc, nil } defer rows.Close() refundRemaining := calc.RefundableAmount // Track which Square payment IDs have already been refunded through Square. // Multiple split payment records (deposit + balance) can share the same // square_payment_id — we must only refund each Square payment once. refundedSquareIDs := make(map[string]bool) for rows.Next() { if refundRemaining <= 0 { break } var paymentID, paymentMethod string var amount float64 var squarePaymentID *string var giftCardID *string if err := rows.Scan(&paymentID, &amount, &paymentMethod, &squarePaymentID, &giftCardID); err != nil { log.Printf("Failed to scan payment row: %v", err) continue } refundThisPayment := math.Min(amount, refundRemaining) refundCents := int64(math.Round(refundThisPayment * 100)) var squareRefundID *string switch paymentMethod { case "online_square", "in_person_card": // Card payments can be refunded through Square if we have a payment reference. // Skip the Square API call if this square_payment_id was already processed // (possible when split payment records share the same charge). if squarePaymentID != nil && *squarePaymentID != "" && !refundedSquareIDs[*squarePaymentID] { refundReq := square.RefundPaymentReq{ PaymentID: *squarePaymentID, Amount: refundCents, IdempotencyKey: paymentID + "-cancel-" + time.Now().Format("20060102150405"), Reason: reason, } result, sqErr := SquareClient.RefundPayment(ctx, refundReq) if sqErr != nil { log.Printf("Square refund failed for payment %s (will record refund locally): %v", paymentID, sqErr) } else { squareRefundID = &result.ID refundedSquareIDs[*squarePaymentID] = true } } else if squarePaymentID != nil && refundedSquareIDs[*squarePaymentID] { log.Printf("Square payment %s already refunded through split record %s — crediting balance for £%.2f", *squarePaymentID, paymentID, refundThisPayment) } // If Square refund failed or wasn't available, credit the user's balance. // Guests don't get balance credits — admin handles those manually. if squareRefundID == nil && bookingUserID != "" { if isGuest { log.Printf("Guest card refund (Square unavailable): booking %s, payment %s, amount £%.2f — admin must process at till", bookingID, paymentID, refundThisPayment) } else { log.Printf("Crediting £%.2f to user %s balance for card payment %s (Square refund unavailable)", refundThisPayment, bookingUserID, paymentID) creditUserBalance(ctx, bookingUserID, bookingID, paymentID, refundThisPayment, reason) } } case "giftcard": if giftCardID == nil || *giftCardID == "" { log.Printf("Giftcard payment %s has no gift_card_id — cannot refund to card. Skipping.", paymentID) break } var expired bool if err := db.DB.QueryRow(ctx, ` SELECT expiry_date IS NOT NULL AND expiry_date < NOW() FROM gift_cards WHERE id = $1 `, *giftCardID).Scan(&expired); err != nil { log.Printf("Failed to check gift card %s expiry: %v — proceeding with refund", *giftCardID, err) } else if expired { log.Printf("Gift card %s has expired — money retained by salon, no refund due for booking %s", *giftCardID, bookingID) break } if _, err := db.DB.Exec(ctx, ` UPDATE gift_cards SET amount_remaining = amount_remaining + $1, last_used_at = NOW() WHERE id = $2 `, refundThisPayment, *giftCardID); err != nil { log.Printf("Failed to refund £%.2f to gift card %s: %v", refundThisPayment, *giftCardID, err) break } if _, err := db.DB.Exec(ctx, ` INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) VALUES ($1, 'refund', $2, 'booking', $3, $4, $5) `, *giftCardID, refundThisPayment, bookingID, bookingUserID, "Refund from cancelled booking"); err != nil { log.Printf("Failed to create gift card transaction for refund: %v", err) } case "cash": if isGuest || bookingUserID == "" { log.Printf("Guest cash refund: booking %s, payment %s, amount £%.2f — admin must process cash refund at till", bookingID, paymentID, refundThisPayment) } else { log.Printf("Crediting £%.2f to user %s balance for cash payment %s", refundThisPayment, bookingUserID, paymentID) creditUserBalance(ctx, bookingUserID, bookingID, paymentID, refundThisPayment, reason) } default: // discount, on_the_house — no real money to refund. log.Printf("Skipping refund for payment %s with method %q (no money exchanged)", paymentID, paymentMethod) } record := RefundRecord{ PaymentID: paymentID, BookingID: bookingID, Amount: refundThisPayment, SquareRefundID: squareRefundID, Status: "completed", Reason: reason, CreatedBy: actorID, CreatedAt: time.Now(), } _, dbErr := db.DB.Exec(ctx, ` INSERT INTO refunds (payment_id, booking_id, amount, square_refund_id, status, reason, created_by, created_at) VALUES ($1, $2, $3, $4, 'completed', $5, $6, $7) `, record.PaymentID, record.BookingID, record.Amount, record.SquareRefundID, record.Reason, record.CreatedBy, record.CreatedAt) if dbErr != nil { log.Printf("Failed to create refund record for payment %s: %v", paymentID, dbErr) continue } refundRemaining -= refundThisPayment } if bookingUserID != "" { var loyaltyUsed bool db.DB.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')", bookingID).Scan(&loyaltyUsed) if loyaltyUsed { _, err := db.DB.Exec(ctx, "UPDATE users SET loyalty_stamps = loyalty_stamps + $1 WHERE id = $2", LoyaltyStampCost, bookingUserID) if err != nil { log.Printf("Failed to refund loyalty stamps for booking %s: %v", bookingID, err) } else { log.Printf("Refunded 10 loyalty stamps to user %s after cancellation of booking %s", bookingUserID, bookingID) } } } return &calc, nil } // creditUserBalance credits a refund amount to the user's gift card balance. // The refunds table (payment_id, booking_id, amount, reason, created_by, created_at) // provides the primary audit trail for FreeAgent reconciliation. func creditUserBalance(ctx context.Context, userID, bookingID, paymentID string, amount float64, reason string) { _, err := db.DB.Exec(ctx, ` INSERT INTO user_giftcard_balances (user_id, balance, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (user_id) DO UPDATE SET balance = user_giftcard_balances.balance + EXCLUDED.balance, updated_at = NOW() `, userID, amount) if err != nil { log.Printf("Failed to credit user %s balance for refund of booking %s: %v", userID, bookingID, err) } }