feat: Square payment integration, booking flow redesign, and timezone/weekday fixes
- Add Square payment integration (mock + handlers + UI): terminal/online payments, refunds, tips, saved cards, webhooks. Build-tagged dev/prod clients. - Redesign booking flow: Step 4 conditional (deposit only), Step 5 confirmation screen with booking ID, auto-submit on transition. - Redesign schedule modal: 2x3 button grid with Pay Deposit/Pay Early logic. - Add deposit warning banner at Step 1 for users with outstanding deposits. - Fix weekday conversion bug: Go 0=Sunday vs DB 0=Monday mismatch in 6 locations. - Fix timezone bug: UTC vs London time in closing hours validation. - Fix frontend error parsing: plain text backend errors now displayed correctly. - Fix crypto.randomUUID fallback for environments without Web Crypto. - Add 7 new regression tests: closing hours, advance check, active booking limit, weekday conversion, UTC/London, deposit snapshot, exceptional hours. - Fix 3 flaky tests: dynamic dates instead of fixed, no-show timing.
This commit is contained in:
@@ -30,3 +30,9 @@ AWS_REGION=eu-west-2
|
||||
# R2_BUCKET=crussell
|
||||
# R2_PUBLIC_URL=https://pub-<your-domain>.r2.dev
|
||||
# AWS_REGION=auto
|
||||
|
||||
# Square Payment Gateway
|
||||
SQUARE_ACCESS_TOKEN=
|
||||
SQUARE_LOCATION_ID=
|
||||
SQUARE_ENVIRONMENT=mock
|
||||
SQUARE_WEBHOOK_SIGNATURE_KEY=
|
||||
|
||||
@@ -1223,21 +1223,23 @@ func TestAdminBookings_Create_DuringHolidayHours_Rejected(t *testing.T) {
|
||||
defer db.DB.Exec(context.Background(), "DELETE FROM exceptional_working_hours_groups WHERE id = $1", groupID)
|
||||
|
||||
// Add closed hours for targetDate (closed all day)
|
||||
// DB convention: 0=Monday..6=Sunday; Go: 0=Sunday..6=Saturday. Convert.
|
||||
dbWeekday := (int(targetDate.Weekday()) + 6) % 7
|
||||
_, err = db.DB.Exec(context.Background(), `
|
||||
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, groupID, int(targetDate.Weekday()), "00:00:00", "23:59:59", false)
|
||||
`, groupID, dbWeekday, "00:00:00", "23:59:59", false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create holiday hours: %v", err)
|
||||
}
|
||||
|
||||
// Apply the group to the week containing targetDate
|
||||
// Must use Monday of that week (matching handler logic)
|
||||
targetWeekday := int(targetDate.Weekday())
|
||||
if targetWeekday == 0 {
|
||||
targetWeekday = 7 // Sunday -> 7
|
||||
daysToMonday := int(targetDate.Weekday())
|
||||
if daysToMonday == 0 {
|
||||
daysToMonday = 7
|
||||
}
|
||||
mondayOfWeek := targetDate.AddDate(0, 0, -targetWeekday+1)
|
||||
mondayOfWeek := targetDate.AddDate(0, 0, -daysToMonday+1)
|
||||
_, err = db.DB.Exec(context.Background(), `
|
||||
INSERT INTO exceptional_group_applications (group_id, week_start)
|
||||
VALUES ($1, $2)
|
||||
@@ -2660,3 +2662,197 @@ func TestAdminBookings_Create_WalkInGuestUser(t *testing.T) {
|
||||
t.Errorf("expected booking user_id = %s, got %s", guestID, foundUserID)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Exceptional Hours Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestAdminUpdateBooking_ClosedExceptionalHours_WarningOnly verifies that an admin can
|
||||
// update a booking's time to fall within a closed exceptional hours period, receiving
|
||||
// a warning but proceeding with the update.
|
||||
func TestAdminUpdateBooking_ClosedExceptionalHours_WarningOnly(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, adminID)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
originalTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test booking: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteBooking(db.DB, bookingID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(),
|
||||
"UPDATE bookings SET start_time = $1 WHERE id = $2", originalTime, bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update booking time: %v", err)
|
||||
}
|
||||
|
||||
targetDate := time.Now().Add(5 * 24 * time.Hour).Truncate(24 * time.Hour)
|
||||
|
||||
var groupID int
|
||||
err = db.DB.QueryRow(context.Background(), `
|
||||
INSERT INTO exceptional_working_hours_groups (name, description)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id
|
||||
`, "Holiday Closure", "Test holiday").Scan(&groupID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create holiday group: %v", err)
|
||||
}
|
||||
|
||||
// DB convention: 0=Monday..6=Sunday; Go: 0=Sunday..6=Saturday. Convert.
|
||||
dbWeekday := (int(targetDate.Weekday()) + 6) % 7
|
||||
_, err = db.DB.Exec(context.Background(), `
|
||||
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, groupID, dbWeekday, "00:00:00", "23:59:59", false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create holiday hours: %v", err)
|
||||
}
|
||||
|
||||
daysToMonday := int(targetDate.Weekday())
|
||||
if daysToMonday == 0 {
|
||||
daysToMonday = 7
|
||||
}
|
||||
mondayOfWeek := targetDate.AddDate(0, 0, -daysToMonday+1)
|
||||
_, err = db.DB.Exec(context.Background(), `
|
||||
INSERT INTO exceptional_group_applications (group_id, week_start)
|
||||
VALUES ($1, $2)
|
||||
`, groupID, mondayOfWeek)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create holiday application: %v", err)
|
||||
}
|
||||
|
||||
targetTime := targetDate.Add(14 * time.Hour).Truncate(time.Second)
|
||||
req := bookings.EditBookingRequest{
|
||||
StartTime: targetTime,
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(bookings.AdminEditBookingHandler)
|
||||
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := parseResponseBody(w, &response); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
warnings, ok := response["warnings"].([]interface{})
|
||||
if !ok || len(warnings) == 0 {
|
||||
t.Error("expected warnings array with at least one warning about working hours")
|
||||
} else {
|
||||
warningStr, ok := warnings[0].(string)
|
||||
if !ok {
|
||||
t.Errorf("expected warning to be a string, got: %v", warnings[0])
|
||||
} else if !bytes.Contains([]byte(warningStr), []byte("working hours")) {
|
||||
t.Errorf("expected warning to mention 'working hours', got: %s", warningStr)
|
||||
}
|
||||
}
|
||||
|
||||
var updatedStartTime time.Time
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&updatedStartTime)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query booking: %v", err)
|
||||
}
|
||||
if !updatedStartTime.Equal(targetTime) {
|
||||
t.Errorf("expected booking start_time %v, got %v", targetTime, updatedStartTime)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCreateBookingForUser_ClosedExceptionalHours_Rejected verifies that an admin
|
||||
// cannot create a booking for a user during hours marked as closed in the exceptional
|
||||
// working hours (holiday) system.
|
||||
func TestAdminCreateBookingForUser_ClosedExceptionalHours_Rejected(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, adminID)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
targetDate := time.Now().Add(5 * 24 * time.Hour).Truncate(24 * time.Hour)
|
||||
|
||||
var groupID int
|
||||
err = db.DB.QueryRow(context.Background(), `
|
||||
INSERT INTO exceptional_working_hours_groups (name, description)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id
|
||||
`, "Holiday Closure", "Test holiday").Scan(&groupID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create holiday group: %v", err)
|
||||
}
|
||||
|
||||
// DB convention: 0=Monday..6=Sunday; Go: 0=Sunday..6=Saturday. Convert.
|
||||
dbWeekday := (int(targetDate.Weekday()) + 6) % 7
|
||||
_, err = db.DB.Exec(context.Background(), `
|
||||
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, groupID, dbWeekday, "00:00:00", "23:59:59", false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create holiday hours: %v", err)
|
||||
}
|
||||
|
||||
daysToMonday := int(targetDate.Weekday())
|
||||
if daysToMonday == 0 {
|
||||
daysToMonday = 7
|
||||
}
|
||||
mondayOfWeek := targetDate.AddDate(0, 0, -daysToMonday+1)
|
||||
_, err = db.DB.Exec(context.Background(), `
|
||||
INSERT INTO exceptional_group_applications (group_id, week_start)
|
||||
VALUES ($1, $2)
|
||||
`, groupID, mondayOfWeek)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create holiday application: %v", err)
|
||||
}
|
||||
|
||||
targetTime := targetDate.Add(14 * time.Hour).Truncate(time.Second)
|
||||
req := bookings.AdminCreateBookingForUserRequest{
|
||||
UserID: userID,
|
||||
StartTime: targetTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
|
||||
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
if !bytes.Contains(w.Body.Bytes(), []byte("holiday hours")) {
|
||||
t.Errorf("expected error message to mention 'holiday hours', got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,9 @@ func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
weekday := int(req.StartTime.Weekday())
|
||||
localStart := req.StartTime.In(londonLocation)
|
||||
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
|
||||
weekday := int((localStart.Weekday() + 6) % 7)
|
||||
var closeStr string
|
||||
if err := db.DB.QueryRow(r.Context(), `SELECT end_time::text FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -117,8 +119,9 @@ func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
|
||||
localEnd := localStart.Add(time.Duration(svcDuration) * time.Minute)
|
||||
closeTime, _ := time.Parse("15:04:05", closeStr)
|
||||
if endTime.Hour() > closeTime.Hour() || (endTime.Hour() == closeTime.Hour() && endTime.Minute() > closeTime.Minute()) {
|
||||
if localEnd.Hour() > closeTime.Hour() || (localEnd.Hour() == closeTime.Hour() && localEnd.Minute() > closeTime.Minute()) {
|
||||
http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1588,7 +1588,9 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
weekday := int(req.StartTime.Weekday())
|
||||
localStart := req.StartTime.In(londonLocation)
|
||||
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
|
||||
weekday := int((localStart.Weekday() + 6) % 7)
|
||||
var closeStr string
|
||||
if err := db.DB.QueryRow(r.Context(), `SELECT end_time::text FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil {
|
||||
log.Printf("Failed to get hours: %v", err)
|
||||
@@ -1597,8 +1599,9 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
|
||||
localEnd := localStart.Add(time.Duration(svcDuration) * time.Minute)
|
||||
closeTime, _ := time.Parse("15:04:05", closeStr)
|
||||
if endTime.Hour() > closeTime.Hour() || (endTime.Hour() == closeTime.Hour() && endTime.Minute() > closeTime.Minute()) {
|
||||
if localEnd.Hour() > closeTime.Hour() || (localEnd.Hour() == closeTime.Hour() && localEnd.Minute() > closeTime.Minute()) {
|
||||
http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -1832,9 +1835,10 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
weekday := int(req.StartTime.Weekday())
|
||||
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
|
||||
weekday := int((req.StartTime.Weekday() + 6) % 7)
|
||||
bookingTime := req.StartTime.Format("15:04:05")
|
||||
daysToMonday := weekday
|
||||
daysToMonday := int(req.StartTime.Weekday())
|
||||
if daysToMonday == 0 {
|
||||
daysToMonday = 7
|
||||
}
|
||||
|
||||
@@ -1651,9 +1651,8 @@ func TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3(t *testing.T) {
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// Create booking with start_time = now + 12 hours (< 24h notice)
|
||||
soonTime := time.Now().Add(12 * time.Hour).Truncate(time.Second)
|
||||
soonTime = time.Date(soonTime.Year(), soonTime.Month(), soonTime.Day(), 10, 0, 0, 0, soonTime.Location())
|
||||
// Create booking with start_time = now + 23 hours (< 24h notice, > 1h advance)
|
||||
soonTime := time.Now().Add(23 * time.Hour).Truncate(time.Second)
|
||||
|
||||
bookingReq := CreateBookingRequest{
|
||||
StartTime: soonTime,
|
||||
@@ -1818,9 +1817,8 @@ func TestDeleteBooking_NoShowWithForgiveness_NoPenalty(t *testing.T) {
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// Create booking with start_time = now + 12 hours (< 24h notice)
|
||||
soonTime := time.Now().Add(12 * time.Hour).Truncate(time.Second)
|
||||
soonTime = time.Date(soonTime.Year(), soonTime.Month(), soonTime.Day(), 10, 0, 0, 0, soonTime.Location())
|
||||
// Create booking with start_time = now + 23 hours (< 24h notice, > 1h advance)
|
||||
soonTime := time.Now().Add(23 * time.Hour).Truncate(time.Second)
|
||||
|
||||
bookingReq := CreateBookingRequest{
|
||||
StartTime: soonTime,
|
||||
@@ -1903,8 +1901,7 @@ func TestDeleteBooking_SecondNoShow_StaysAt3(t *testing.T) {
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// === First booking: no-show ===
|
||||
soonTime1 := time.Now().Add(12 * time.Hour).Truncate(time.Second)
|
||||
soonTime1 = time.Date(soonTime1.Year(), soonTime1.Month(), soonTime1.Day(), 10, 0, 0, 0, soonTime1.Location())
|
||||
soonTime1 := time.Now().Add(23 * time.Hour).Truncate(time.Second)
|
||||
|
||||
bookingReq1 := CreateBookingRequest{
|
||||
StartTime: soonTime1,
|
||||
@@ -1944,10 +1941,7 @@ func TestDeleteBooking_SecondNoShow_StaysAt3(t *testing.T) {
|
||||
}
|
||||
|
||||
// === Second booking: no-show ===
|
||||
// Need to wait a bit or create with different time to avoid conflict
|
||||
// Use tomorrow + 12 hours
|
||||
soonTime2 := time.Now().Add(36 * time.Hour).Truncate(time.Second)
|
||||
soonTime2 = time.Date(soonTime2.Year(), soonTime2.Month(), soonTime2.Day(), 10, 0, 0, 0, soonTime2.Location())
|
||||
soonTime2 := time.Now().Add(47 * time.Hour).Truncate(time.Second)
|
||||
|
||||
bookingReq2 := CreateBookingRequest{
|
||||
StartTime: soonTime2,
|
||||
@@ -4149,8 +4143,9 @@ func TestBookings_Edit_ClosedDay_UserBlocked(t *testing.T) {
|
||||
|
||||
// Calculate week start for the booking target date
|
||||
targetDate := time.Now().Add(96 * time.Hour)
|
||||
weekday := int(targetDate.Weekday())
|
||||
daysToMonday := weekday
|
||||
// DB convention: 0=Monday..6=Sunday; Go: 0=Sunday..6=Saturday. Convert.
|
||||
dbWeekday := (int(targetDate.Weekday()) + 6) % 7
|
||||
daysToMonday := int(targetDate.Weekday())
|
||||
if daysToMonday == 0 {
|
||||
daysToMonday = 7
|
||||
}
|
||||
@@ -4169,7 +4164,7 @@ func TestBookings_Edit_ClosedDay_UserBlocked(t *testing.T) {
|
||||
_, err = db.DB.Exec(context.Background(),
|
||||
`INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
|
||||
VALUES ($1, $2, '08:00:00', '20:00:00', false)`,
|
||||
groupID, weekday)
|
||||
groupID, dbWeekday)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create closed exceptional hours: %v", err)
|
||||
}
|
||||
@@ -4841,3 +4836,356 @@ func TestCreateBooking_Notifications_NoPendingBookingWithoutNotes(t *testing.T)
|
||||
t.Errorf("expected 0 pending_booking notifications for booking without notes, got %d", pendingCount)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Closing Hours, Advance Check, and Active Booking Limit Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestCreateBooking_ClosingHoursValidation(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
london, err := time.LoadLocation("Europe/London")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load London timezone: %v", err)
|
||||
}
|
||||
|
||||
hours := []struct {
|
||||
weekday int
|
||||
startTime string
|
||||
endTime string
|
||||
isOpen bool
|
||||
}{
|
||||
{0, "08:00", "17:00", true},
|
||||
{1, "08:00", "20:00", true},
|
||||
{2, "08:00", "20:00", true},
|
||||
{3, "08:00", "20:00", true},
|
||||
{4, "08:00", "20:00", true},
|
||||
{5, "08:00", "20:00", true},
|
||||
{6, "08:00", "20:00", true},
|
||||
}
|
||||
seedCustomWorkingHours(t, hours)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE services SET duration_minutes = 60 WHERE id = $1", serviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set service duration: %v", err)
|
||||
}
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
thursday := nextWeekday(time.Thursday, london)
|
||||
thursdayStart := time.Date(thursday.Year(), thursday.Month(), thursday.Day(), 17, 30, 0, 0, london)
|
||||
req1 := CreateBookingRequest{
|
||||
StartTime: thursdayStart,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(CreateBookingHandler)
|
||||
w1 := makeRequest(handler, "POST", "/api/bookings", req1, token)
|
||||
|
||||
if w1.Code != http.StatusCreated {
|
||||
t.Errorf("Thursday 17:30+60min should succeed (ends 18:30 < 20:00), got %d. body: %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
|
||||
monday := nextWeekday(time.Monday, london)
|
||||
mondayStart := time.Date(monday.Year(), monday.Month(), monday.Day(), 16, 30, 0, 0, london)
|
||||
req2 := CreateBookingRequest{
|
||||
StartTime: mondayStart,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
w2 := makeRequest(handler, "POST", "/api/bookings", req2, token)
|
||||
|
||||
if w2.Code != http.StatusBadRequest {
|
||||
t.Errorf("Monday 16:30+60min should fail (ends 17:30 > 17:00), got %d. body: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
if !bytes.Contains(w2.Body.Bytes(), []byte("closing")) {
|
||||
t.Errorf("expected error about closing hours, got: %s", w2.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBooking_OneHourAdvanceCheck(t *testing.T) {
|
||||
resetTestData(t)
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
handler := http.HandlerFunc(CreateBookingHandler)
|
||||
|
||||
soonTime := time.Now().Add(30 * time.Minute).Truncate(time.Second)
|
||||
req1 := CreateBookingRequest{
|
||||
StartTime: soonTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
w1 := makeRequest(handler, "POST", "/api/bookings", req1, token)
|
||||
|
||||
if w1.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400 for 30-min advance booking, got %d. body: %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
|
||||
if !bytes.Contains(w1.Body.Bytes(), []byte("at least 1 hour")) {
|
||||
t.Errorf("expected error about 1 hour advance, got: %s", w1.Body.String())
|
||||
}
|
||||
|
||||
aheadTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||
aheadTime = time.Date(aheadTime.Year(), aheadTime.Month(), aheadTime.Day(), 10, 0, 0, 0, aheadTime.Location())
|
||||
req2 := CreateBookingRequest{
|
||||
StartTime: aheadTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
w2 := makeRequest(handler, "POST", "/api/bookings", req2, token)
|
||||
|
||||
if w2.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201 for 2h+ advance booking, got %d. body: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBooking_ActiveBookingLimit(t *testing.T) {
|
||||
resetTestData(t)
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
handler := http.HandlerFunc(CreateBookingHandler)
|
||||
|
||||
firstTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||
firstTime = time.Date(firstTime.Year(), firstTime.Month(), firstTime.Day(), 10, 0, 0, 0, firstTime.Location())
|
||||
req1 := CreateBookingRequest{
|
||||
StartTime: firstTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
w1 := makeRequest(handler, "POST", "/api/bookings", req1, token)
|
||||
|
||||
if w1.Code != http.StatusCreated {
|
||||
t.Fatalf("expected first booking to succeed, got %d. body: %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
|
||||
var booking1 Booking
|
||||
if err := parseResponseBody(w1, &booking1); err != nil {
|
||||
t.Fatalf("failed to parse first booking: %v", err)
|
||||
}
|
||||
|
||||
secondTime := time.Now().Add(96 * time.Hour).Truncate(time.Second)
|
||||
secondTime = time.Date(secondTime.Year(), secondTime.Month(), secondTime.Day(), 14, 0, 0, 0, secondTime.Location())
|
||||
req2 := CreateBookingRequest{
|
||||
StartTime: secondTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
w2 := makeRequest(handler, "POST", "/api/bookings", req2, token)
|
||||
|
||||
if w2.Code != http.StatusConflict {
|
||||
t.Errorf("expected status 409 for second booking with active booking, got %d. body: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
if !bytes.Contains(w2.Body.Bytes(), []byte("active booking")) {
|
||||
t.Errorf("expected error about active booking, got: %s", w2.Body.String())
|
||||
}
|
||||
|
||||
_, err = db.DB.Exec(context.Background(),
|
||||
"UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", booking1.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to cancel first booking: %v", err)
|
||||
}
|
||||
|
||||
w3 := makeRequest(handler, "POST", "/api/bookings", req2, token)
|
||||
|
||||
if w3.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201 after cancelling active booking, got %d. body: %s", w3.Code, w3.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextWeekdayHelper(t *testing.T) {
|
||||
london, err := time.LoadLocation("Europe/London")
|
||||
if err != nil {
|
||||
t.Fatalf("Europe/London not available: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
weekday time.Weekday
|
||||
}{
|
||||
{"Monday", time.Monday},
|
||||
{"Tuesday", time.Tuesday},
|
||||
{"Wednesday", time.Wednesday},
|
||||
{"Thursday", time.Thursday},
|
||||
{"Friday", time.Friday},
|
||||
{"Saturday", time.Saturday},
|
||||
{"Sunday", time.Sunday},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := nextWeekday(tt.weekday, london)
|
||||
|
||||
if result.Weekday() != tt.weekday {
|
||||
t.Errorf("expected weekday %s, got %s", tt.weekday, result.Weekday())
|
||||
}
|
||||
|
||||
now := time.Now().In(london)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, london)
|
||||
resultDay := time.Date(result.Year(), result.Month(), result.Day(), 0, 0, 0, 0, london)
|
||||
daysDiff := int(resultDay.Sub(today).Hours() / 24)
|
||||
if daysDiff < 2 {
|
||||
t.Errorf("expected result to be at least 2 calendar days ahead, got %d", daysDiff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBooking_DepositSnapshot(t *testing.T) {
|
||||
resetTestData(t)
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
london, err := time.LoadLocation("Europe/London")
|
||||
if err != nil {
|
||||
t.Fatalf("Europe/London not available: %v", err)
|
||||
}
|
||||
bookingTime := nextWeekday(time.Monday, london).Add(10 * time.Hour)
|
||||
|
||||
req := CreateBookingRequest{
|
||||
StartTime: bookingTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(CreateBookingHandler)
|
||||
w := makeRequest(handler, "POST", "/api/bookings", req, token)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var booking Booking
|
||||
if err := parseResponseBody(w, &booking); err != nil {
|
||||
t.Fatalf("failed to parse booking response: %v", err)
|
||||
}
|
||||
|
||||
if !booking.DepositRequired {
|
||||
t.Error("expected deposit_required=true on first booking")
|
||||
}
|
||||
|
||||
var depositRequired bool
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT deposit_required FROM bookings WHERE id = $1", booking.ID).Scan(&depositRequired)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query booking: %v", err)
|
||||
}
|
||||
if !depositRequired {
|
||||
t.Error("expected deposit_required=true in DB for first booking")
|
||||
}
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update deposits_required: %v", err)
|
||||
}
|
||||
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT deposit_required FROM bookings WHERE id = $1", booking.ID).Scan(&depositRequired)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query booking after user update: %v", err)
|
||||
}
|
||||
if !depositRequired {
|
||||
t.Error("expected booking deposit_required to remain true after user change")
|
||||
}
|
||||
|
||||
bookingTime2 := nextWeekday(time.Tuesday, london).Add(10 * time.Hour)
|
||||
req2 := CreateBookingRequest{
|
||||
StartTime: bookingTime2,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
w2 := makeRequest(handler, "POST", "/api/bookings", req2, token)
|
||||
if w2.Code != http.StatusCreated {
|
||||
t.Fatalf("expected status 201 for second booking, got %d. body: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
var booking2 Booking
|
||||
if err := parseResponseBody(w2, &booking2); err != nil {
|
||||
t.Fatalf("failed to parse second booking response: %v", err)
|
||||
}
|
||||
|
||||
if booking2.DepositRequired {
|
||||
t.Error("expected deposit_required=false on second booking after user deposits_required=0")
|
||||
}
|
||||
|
||||
var depositRequired2 bool
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT deposit_required FROM bookings WHERE id = $1", booking2.ID).Scan(&depositRequired2)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query second booking: %v", err)
|
||||
}
|
||||
if depositRequired2 {
|
||||
t.Error("expected second booking deposit_required=false in DB")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,9 +317,10 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Check if salon is closed (exceptional hours) - admin gets warning but can proceed
|
||||
weekday := int(req.StartTime.Weekday())
|
||||
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
|
||||
weekday := int((req.StartTime.Weekday() + 6) % 7)
|
||||
bookingTime := req.StartTime.Format("15:04:05")
|
||||
daysToMonday := weekday
|
||||
daysToMonday := int(req.StartTime.Weekday())
|
||||
if daysToMonday == 0 {
|
||||
daysToMonday = 7
|
||||
}
|
||||
@@ -612,8 +613,9 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Check if booking time falls within a closed exceptional hours period
|
||||
// Calculate the Monday of the week containing the booking date
|
||||
weekday := int(req.StartTime.Weekday())
|
||||
daysToMonday := weekday
|
||||
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
|
||||
weekday := int((req.StartTime.Weekday() + 6) % 7)
|
||||
daysToMonday := int(req.StartTime.Weekday())
|
||||
if daysToMonday == 0 {
|
||||
daysToMonday = 7 // Sunday -> next Monday
|
||||
}
|
||||
|
||||
@@ -106,7 +106,9 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// f. Validate working hours: SELECT end_time::text FROM working_hours WHERE weekday = $1
|
||||
weekday := int(req.StartTime.Weekday())
|
||||
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
|
||||
localStart := req.StartTime.In(londonLocation)
|
||||
weekday := int((localStart.Weekday() + 6) % 7)
|
||||
var closeStr string
|
||||
if err := db.DB.QueryRow(r.Context(), `SELECT end_time::text FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil {
|
||||
log.Printf("Failed to get hours: %v", err)
|
||||
@@ -114,14 +116,15 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
|
||||
localEnd := localStart.Add(time.Duration(svcDuration) * time.Minute)
|
||||
closeTime, _ := time.Parse("15:04:05", closeStr)
|
||||
if endTime.Hour() > closeTime.Hour() || (endTime.Hour() == closeTime.Hour() && endTime.Minute() > closeTime.Minute()) {
|
||||
if localEnd.Hour() > closeTime.Hour() || (localEnd.Hour() == closeTime.Hour() && localEnd.Minute() > closeTime.Minute()) {
|
||||
http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// g. Check existing booking overlap (same query as CreateBookingHandler)
|
||||
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
|
||||
var cnt int
|
||||
db.DB.QueryRow(r.Context(), `
|
||||
SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','in_progress','completed')
|
||||
|
||||
@@ -365,3 +365,276 @@ func TestReserveSlot_DualCleanup(t *testing.T) {
|
||||
t.Error("expected 2-hour user reservation to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// seedCustomWorkingHours replaces working_hours with the given schedule.
|
||||
// DB convention: 0=Monday, 1=Tuesday, ..., 6=Sunday.
|
||||
func seedCustomWorkingHours(t *testing.T, hours []struct {
|
||||
weekday int
|
||||
startTime string
|
||||
endTime string
|
||||
isOpen bool
|
||||
}) {
|
||||
t.Helper()
|
||||
for _, h := range hours {
|
||||
_, err := db.DB.Exec(context.Background(), `
|
||||
INSERT INTO working_hours (weekday, start_time, end_time, is_open)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (weekday) DO UPDATE SET start_time = $2, end_time = $3, is_open = $4
|
||||
`, h.weekday, h.startTime, h.endTime, h.isOpen)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed working hours for weekday %d: %v", h.weekday, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nextWeekday returns the next occurrence of the given weekday (0=Sunday..6=Saturday)
|
||||
// in the given location, at least 2 days from now to avoid "in the past" rejections.
|
||||
func nextWeekday(weekday time.Weekday, loc *time.Location) time.Time {
|
||||
now := time.Now().In(loc)
|
||||
daysAhead := int(weekday) - int(now.Weekday())
|
||||
if daysAhead <= 0 {
|
||||
daysAhead += 7
|
||||
}
|
||||
if daysAhead < 2 {
|
||||
daysAhead += 7
|
||||
}
|
||||
return now.AddDate(0, 0, daysAhead).Truncate(24 * time.Hour)
|
||||
}
|
||||
|
||||
// TestReserveSlot_WeekdayConversion verifies that Go's time.Weekday (0=Sunday)
|
||||
// is correctly mapped to the DB's weekday convention (0=Monday).
|
||||
func TestReserveSlot_WeekdayConversion(t *testing.T) {
|
||||
hours := []struct {
|
||||
weekday int
|
||||
startTime string
|
||||
endTime string
|
||||
isOpen bool
|
||||
}{
|
||||
{0, "09:00", "17:00", true},
|
||||
{1, "09:00", "17:00", true},
|
||||
{2, "09:00", "17:00", true},
|
||||
{3, "09:00", "17:00", true},
|
||||
{4, "09:00", "17:00", true},
|
||||
{5, "10:00", "14:00", true},
|
||||
{6, "00:00", "00:00", false},
|
||||
}
|
||||
|
||||
testdb.TruncateTables(t, db.DB)
|
||||
seedCustomWorkingHours(t, hours)
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
|
||||
london, err := time.LoadLocation("Europe/London")
|
||||
if err != nil {
|
||||
t.Fatalf("Europe/London not available: %v", err)
|
||||
}
|
||||
|
||||
monday := nextWeekday(time.Monday, london)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
startTime time.Time
|
||||
expectCode int
|
||||
}{
|
||||
{"Monday 10:00", monday.Add(10 * time.Hour), http.StatusCreated},
|
||||
{"Tuesday 10:00", monday.AddDate(0, 0, 1).Add(10 * time.Hour), http.StatusCreated},
|
||||
{"Wednesday 10:00", monday.AddDate(0, 0, 2).Add(10 * time.Hour), http.StatusCreated},
|
||||
{"Thursday 10:00", monday.AddDate(0, 0, 3).Add(10 * time.Hour), http.StatusCreated},
|
||||
{"Friday 10:00", monday.AddDate(0, 0, 4).Add(10 * time.Hour), http.StatusCreated},
|
||||
{"Saturday 11:00", monday.AddDate(0, 0, 5).Add(11 * time.Hour), http.StatusCreated},
|
||||
{"Sunday 11:00", monday.AddDate(0, 0, 6).Add(11 * time.Hour), http.StatusBadRequest},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{
|
||||
StartTime: tt.startTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}, "")
|
||||
|
||||
if w.Code != tt.expectCode {
|
||||
t.Errorf("%s: expected status %d, got %d. body: %s", tt.name, tt.expectCode, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReserveSlot_ClosingHoursValidation verifies that bookings extending
|
||||
// past closing time are rejected, and that the correct day's closing time
|
||||
// is used (not the wrong day due to weekday mismatch).
|
||||
func TestReserveSlot_ClosingHoursValidation(t *testing.T) {
|
||||
hours := []struct {
|
||||
weekday int
|
||||
startTime string
|
||||
endTime string
|
||||
isOpen bool
|
||||
}{
|
||||
{0, "09:00", "17:00", true},
|
||||
{1, "09:00", "17:00", true},
|
||||
{2, "09:00", "17:00", true},
|
||||
{3, "12:00", "20:00", true},
|
||||
{4, "09:00", "17:00", true},
|
||||
{5, "10:00", "14:00", true},
|
||||
{6, "00:00", "00:00", false},
|
||||
}
|
||||
|
||||
testdb.TruncateTables(t, db.DB)
|
||||
seedCustomWorkingHours(t, hours)
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
|
||||
london, err := time.LoadLocation("Europe/London")
|
||||
if err != nil {
|
||||
t.Fatalf("Europe/London not available: %v", err)
|
||||
}
|
||||
|
||||
monday := nextWeekday(time.Monday, london)
|
||||
thursday := monday.AddDate(0, 0, 3)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
startTime time.Time
|
||||
expectCode int
|
||||
}{
|
||||
{"Thursday 17:30 (within 20:00 close)", thursday.Add(17*time.Hour + 30*time.Minute), http.StatusCreated},
|
||||
{"Thursday 19:30 (past 20:00 close)", thursday.Add(19*time.Hour + 30*time.Minute), http.StatusBadRequest},
|
||||
{"Monday 16:30 (past 17:00 close)", monday.Add(16*time.Hour + 30*time.Minute), http.StatusBadRequest},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{
|
||||
StartTime: tt.startTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}, "")
|
||||
|
||||
if w.Code != tt.expectCode {
|
||||
t.Errorf("%s: expected status %d, got %d. body: %s", tt.name, tt.expectCode, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReserveSlot_UTCtoLondonConversion verifies that a UTC timestamp sent
|
||||
// from the browser is correctly interpreted as London local time for the
|
||||
// purpose of working hours lookup.
|
||||
func TestReserveSlot_UTCtoLondonConversion(t *testing.T) {
|
||||
hours := []struct {
|
||||
weekday int
|
||||
startTime string
|
||||
endTime string
|
||||
isOpen bool
|
||||
}{
|
||||
{0, "09:00", "17:00", true},
|
||||
{1, "09:00", "17:00", true},
|
||||
{2, "09:00", "17:00", true},
|
||||
{3, "12:00", "20:00", true},
|
||||
{4, "09:00", "17:00", true},
|
||||
{5, "10:00", "14:00", true},
|
||||
{6, "00:00", "00:00", false},
|
||||
}
|
||||
|
||||
testdb.TruncateTables(t, db.DB)
|
||||
seedCustomWorkingHours(t, hours)
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
|
||||
london, err := time.LoadLocation("Europe/London")
|
||||
if err != nil {
|
||||
t.Fatalf("Europe/London not available: %v", err)
|
||||
}
|
||||
|
||||
thursday := nextWeekday(time.Thursday, london)
|
||||
// Convert to UTC for the request (frontend sends UTC)
|
||||
thursday1730BST := thursday.Add(17*time.Hour + 30*time.Minute).In(london).UTC()
|
||||
thursday1930BST := thursday.Add(19*time.Hour + 30*time.Minute).In(london).UTC()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
startTime time.Time
|
||||
expectCode int
|
||||
}{
|
||||
{"17:30 BST Thursday (within 20:00 close)", thursday1730BST, http.StatusCreated},
|
||||
{"19:30 BST Thursday (past 20:00 close)", thursday1930BST, http.StatusBadRequest},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{
|
||||
StartTime: tt.startTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}, "")
|
||||
|
||||
if w.Code != tt.expectCode {
|
||||
t.Errorf("%s: expected status %d, got %d. body: %s", tt.name, tt.expectCode, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReserveSlot_DifferentClosingPerDay verifies that each day's closing
|
||||
// time is used independently — a late-booking on a late-closing day should
|
||||
// succeed while the same time on an early-closing day should fail.
|
||||
func TestReserveSlot_DifferentClosingPerDay(t *testing.T) {
|
||||
hours := []struct {
|
||||
weekday int
|
||||
startTime string
|
||||
endTime string
|
||||
isOpen bool
|
||||
}{
|
||||
{0, "09:00", "17:00", true},
|
||||
{1, "09:00", "17:00", true},
|
||||
{2, "12:00", "17:00", true},
|
||||
{3, "12:00", "20:00", true},
|
||||
{4, "09:00", "17:00", true},
|
||||
{5, "10:00", "14:00", true},
|
||||
{6, "00:00", "00:00", false},
|
||||
}
|
||||
|
||||
testdb.TruncateTables(t, db.DB)
|
||||
seedCustomWorkingHours(t, hours)
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
|
||||
london, err := time.LoadLocation("Europe/London")
|
||||
if err != nil {
|
||||
t.Fatalf("Europe/London not available: %v", err)
|
||||
}
|
||||
|
||||
wednesday := nextWeekday(time.Wednesday, london)
|
||||
thursday := wednesday.AddDate(0, 0, 1)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
startTime time.Time
|
||||
expectCode int
|
||||
}{
|
||||
{"Wednesday 18:00 (closes 17:00)", wednesday.Add(18 * time.Hour), http.StatusBadRequest},
|
||||
{"Thursday 18:00 (closes 20:00)", thursday.Add(18 * time.Hour), http.StatusCreated},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{
|
||||
StartTime: tt.startTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}, "")
|
||||
|
||||
if w.Code != tt.expectCode {
|
||||
t.Errorf("%s: expected status %d, got %d. body: %s", tt.name, tt.expectCode, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,770 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"crussell/internal/square"
|
||||
"crussell/mw"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type CreateTerminalPaymentRequest struct {
|
||||
Amount int64 `json:"amount"`
|
||||
PaymentType string `json:"payment_type"`
|
||||
OverrideAmount *int64 `json:"override_amount,omitempty"`
|
||||
TipEnabled bool `json:"tip_enabled"`
|
||||
}
|
||||
|
||||
type CreateBookingPaymentRequest struct {
|
||||
Amount int64 `json:"amount"`
|
||||
PaymentType string `json:"payment_type"`
|
||||
CardID *string `json:"card_id,omitempty"`
|
||||
NewCardToken *string `json:"new_card_token,omitempty"`
|
||||
SaveCard bool `json:"save_card"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
}
|
||||
|
||||
type RefundRequest struct {
|
||||
Amount int64 `json:"amount"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type CreateTipPaymentRequest struct {
|
||||
Amount int64 `json:"amount"`
|
||||
CardToken string `json:"card_token"`
|
||||
}
|
||||
|
||||
type CheckoutResponse struct {
|
||||
CheckoutID string `json:"checkout_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type PaymentStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
PaymentID string `json:"payment_id,omitempty"`
|
||||
Amount int64 `json:"amount,omitempty"`
|
||||
CardBrand string `json:"card_brand,omitempty"`
|
||||
CardLast4 string `json:"card_last4,omitempty"`
|
||||
ReceiptURL string `json:"receipt_url,omitempty"`
|
||||
}
|
||||
|
||||
type PaymentResponse struct {
|
||||
ID string `json:"id"`
|
||||
BookingID string `json:"booking_id"`
|
||||
PaymentType string `json:"payment_type"`
|
||||
Status string `json:"status"`
|
||||
Amount int64 `json:"amount"`
|
||||
CardBrand string `json:"card_brand,omitempty"`
|
||||
CardLast4 string `json:"card_last4,omitempty"`
|
||||
ReceiptURL string `json:"receipt_url,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type RefundResponse struct {
|
||||
ID string `json:"id"`
|
||||
PaymentID string `json:"payment_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type PaymentSummaryResponse struct {
|
||||
TotalAmount int64 `json:"total_amount"`
|
||||
PaidAmount int64 `json:"paid_amount"`
|
||||
RefundedAmount int64 `json:"refunded_amount"`
|
||||
RemainingAmount int64 `json:"remaining_amount"`
|
||||
Payments []PaymentResponse `json:"payments"`
|
||||
Refunds []RefundResponse `json:"refunds"`
|
||||
}
|
||||
|
||||
func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
bookingID := chi.URLParam(r, "id")
|
||||
if bookingID == "" {
|
||||
http.Error(w, "Booking ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
|
||||
if !ok || adminID == "" {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateTerminalPaymentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Printf("Failed to decode terminal payment request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ValidateAmount(req.Amount); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ValidatePaymentType(req.PaymentType); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
service := NewPaymentService()
|
||||
|
||||
status, err := service.GetBookingStatus(r.Context(), bookingID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to get booking status: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if status != "in_progress" && status != "completed" {
|
||||
http.Error(w, "Booking must be in_progress or completed to create payment", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
amount := req.Amount
|
||||
if req.OverrideAmount != nil {
|
||||
amount = *req.OverrideAmount
|
||||
}
|
||||
|
||||
idempotencyKey := bookingID + "-" + req.PaymentType + "-" + strconv.FormatInt(amount, 10)
|
||||
|
||||
existingPayment, err := service.CheckIdempotency(r.Context(), bookingID, idempotencyKey)
|
||||
if err != nil {
|
||||
log.Printf("Failed to check idempotency: %v", err)
|
||||
}
|
||||
if existingPayment != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CheckoutResponse{
|
||||
CheckoutID: existingPayment.ID,
|
||||
Status: existingPayment.Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
checkoutReq := square.CreateCheckoutReq{
|
||||
Amount: amount,
|
||||
Currency: "GBP",
|
||||
IdempotencyKey: idempotencyKey,
|
||||
ReferenceID: bookingID,
|
||||
TipEnabled: req.TipEnabled,
|
||||
}
|
||||
|
||||
checkout, err := SquareClient.CreateCheckout(r.Context(), checkoutReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create checkout: %v", err)
|
||||
http.Error(w, "Failed to create payment", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CheckoutResponse{
|
||||
CheckoutID: checkout.ID,
|
||||
Status: checkout.Status,
|
||||
})
|
||||
_ = adminID
|
||||
}
|
||||
|
||||
func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) {
|
||||
checkoutID := chi.URLParam(r, "checkout_id")
|
||||
if checkoutID == "" {
|
||||
http.Error(w, "Checkout ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
bookingID := r.URL.Query().Get("booking_id")
|
||||
if bookingID == "" {
|
||||
http.Error(w, "booking_id query parameter is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
paymentResult, err := SquareClient.GetCheckout(r.Context(), checkoutID)
|
||||
if err != nil {
|
||||
if err.Error() == "checkout pending" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(PaymentStatusResponse{Status: "PENDING"})
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to get checkout status: %v", err)
|
||||
http.Error(w, "Failed to get checkout status", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if paymentResult.Status == "COMPLETED" {
|
||||
service := NewPaymentService()
|
||||
|
||||
existing, err := service.CheckIdempotency(r.Context(), bookingID, "")
|
||||
if err != nil {
|
||||
log.Printf("Failed to check for existing payment: %v", err)
|
||||
}
|
||||
if existing != nil && existing.SquarePaymentID != nil && *existing.SquarePaymentID == paymentResult.SquarePayID {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(PaymentStatusResponse{
|
||||
Status: "COMPLETED",
|
||||
PaymentID: existing.ID,
|
||||
Amount: existing.Amount,
|
||||
CardBrand: paymentResult.CardBrand,
|
||||
CardLast4: paymentResult.CardLast4,
|
||||
ReceiptURL: paymentResult.ReceiptURL,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
idempotencyKey := bookingID + "-terminal-" + strconv.FormatInt(paymentResult.Amount, 10)
|
||||
|
||||
record := PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
PaymentType: "full",
|
||||
PaymentMethod: "in_person_card",
|
||||
Status: "completed",
|
||||
Amount: paymentResult.Amount,
|
||||
SquarePaymentID: &paymentResult.SquarePayID,
|
||||
IdempotencyKey: &idempotencyKey,
|
||||
Fees: paymentResult.Fees,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
paymentID, err := service.CreatePaymentRecord(r.Context(), record)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create payment record: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(PaymentStatusResponse{
|
||||
Status: "COMPLETED",
|
||||
PaymentID: paymentID,
|
||||
Amount: paymentResult.Amount,
|
||||
CardBrand: paymentResult.CardBrand,
|
||||
CardLast4: paymentResult.CardLast4,
|
||||
ReceiptURL: paymentResult.ReceiptURL,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "Payment failed", http.StatusPaymentRequired)
|
||||
}
|
||||
|
||||
func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
bookingID := chi.URLParam(r, "id")
|
||||
if bookingID == "" {
|
||||
http.Error(w, "Booking ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
||||
if !ok || userID == "" {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateBookingPaymentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Printf("Failed to decode booking payment request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ValidateAmount(req.Amount); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ValidatePaymentType(req.PaymentType); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ValidateCardInfo(req.CardID, req.NewCardToken); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
service := NewPaymentService()
|
||||
|
||||
bookingUserID, err := service.GetBookingUserID(r.Context(), bookingID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to get booking user: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if bookingUserID != userID {
|
||||
http.Error(w, "Unauthorized", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
existingPayment, err := service.CheckIdempotency(r.Context(), bookingID, req.IdempotencyKey)
|
||||
if err != nil {
|
||||
log.Printf("Failed to check idempotency: %v", err)
|
||||
}
|
||||
if existingPayment != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(PaymentResponse{
|
||||
ID: existingPayment.ID,
|
||||
BookingID: existingPayment.BookingID,
|
||||
PaymentType: existingPayment.PaymentType,
|
||||
Status: existingPayment.Status,
|
||||
Amount: existingPayment.Amount,
|
||||
CreatedAt: existingPayment.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var sourceID string
|
||||
var savedCardID *string
|
||||
|
||||
if req.NewCardToken != nil && *req.NewCardToken != "" {
|
||||
cardOnFile, err := SquareClient.CreateCardOnFile(r.Context(), userID, *req.NewCardToken)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create card on file: %v", err)
|
||||
http.Error(w, "Failed to process card", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
sourceID = cardOnFile.CardID
|
||||
|
||||
if req.SaveCard {
|
||||
cardID, err := service.SaveCardForUser(r.Context(), userID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint)
|
||||
if err != nil {
|
||||
log.Printf("Failed to save card: %v", err)
|
||||
} else {
|
||||
savedCardID = &cardID
|
||||
}
|
||||
}
|
||||
} else if req.CardID != nil {
|
||||
card, err := service.GetCardByID(r.Context(), *req.CardID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Card not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to get card: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sourceID = card.SquareCardID
|
||||
savedCardID = req.CardID
|
||||
}
|
||||
|
||||
paymentReq := square.CreatePaymentReq{
|
||||
Amount: req.Amount,
|
||||
Currency: "GBP",
|
||||
SourceID: sourceID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
ReferenceID: bookingID,
|
||||
Note: req.PaymentType,
|
||||
}
|
||||
|
||||
paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create payment: %v", err)
|
||||
http.Error(w, "Payment failed", http.StatusPaymentRequired)
|
||||
return
|
||||
}
|
||||
|
||||
fees := service.CalculateFees(req.Amount, "online")
|
||||
|
||||
record := PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
PaymentType: req.PaymentType,
|
||||
PaymentMethod: "online_square",
|
||||
Status: "completed",
|
||||
Amount: req.Amount,
|
||||
SquarePaymentID: &paymentResult.SquarePayID,
|
||||
IdempotencyKey: &req.IdempotencyKey,
|
||||
Fees: fees,
|
||||
UserSavedCardID: savedCardID,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
CreatedBy: &userID,
|
||||
}
|
||||
|
||||
paymentID, err := service.CreatePaymentRecord(r.Context(), record)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create payment record: %v", err)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(PaymentResponse{
|
||||
ID: paymentID,
|
||||
BookingID: bookingID,
|
||||
PaymentType: req.PaymentType,
|
||||
Status: "completed",
|
||||
Amount: req.Amount,
|
||||
CardBrand: paymentResult.CardBrand,
|
||||
CardLast4: paymentResult.CardLast4,
|
||||
ReceiptURL: paymentResult.ReceiptURL,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func GetUserPaymentMethods(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
||||
if !ok || userID == "" {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
service := NewPaymentService()
|
||||
cards, err := service.GetUserPaymentMethods(r.Context(), userID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get payment methods: %v", 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 == "" {
|
||||
http.Error(w, "Card ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
||||
if !ok || userID == "" {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
service := NewPaymentService()
|
||||
err := service.DeletePaymentMethod(r.Context(), cardID, userID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to delete payment method: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
func RefundPayment(w http.ResponseWriter, r *http.Request) {
|
||||
paymentID := chi.URLParam(r, "payment_id")
|
||||
if paymentID == "" {
|
||||
http.Error(w, "Payment ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
|
||||
if !ok || adminID == "" {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req RefundRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Printf("Failed to decode refund request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ValidateAmount(req.Amount); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ValidateRefundReason(req.Reason); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
service := NewPaymentService()
|
||||
|
||||
payment, err := service.GetPaymentByID(r.Context(), paymentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Payment not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to get payment: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if payment.Status != "completed" {
|
||||
http.Error(w, "Can only refund completed payments", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if payment.SquarePaymentID == nil {
|
||||
http.Error(w, "Payment has no Square reference", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
alreadyRefunded, err := service.GetAlreadyRefundedAmount(r.Context(), paymentID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get already refunded amount: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Amount+alreadyRefunded > payment.Amount {
|
||||
http.Error(w, "Refund amount exceeds payment amount", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
refundReq := square.RefundPaymentReq{
|
||||
PaymentID: *payment.SquarePaymentID,
|
||||
Amount: req.Amount,
|
||||
IdempotencyKey: paymentID + "-" + strconv.FormatInt(req.Amount, 10),
|
||||
Reason: req.Reason,
|
||||
}
|
||||
|
||||
refundResult, err := SquareClient.RefundPayment(r.Context(), refundReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to refund payment: %v", err)
|
||||
http.Error(w, "Refund failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
squareRefundID := refundResult.ID
|
||||
record := RefundRecord{
|
||||
PaymentID: paymentID,
|
||||
BookingID: payment.BookingID,
|
||||
Amount: req.Amount,
|
||||
SquareRefundID: &squareRefundID,
|
||||
Status: "completed",
|
||||
Reason: req.Reason,
|
||||
CreatedBy: &adminID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
refundID, err := service.CreateRefundRecord(r.Context(), record)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create refund record: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if payment.PaymentType == "deposit" && (req.Amount+alreadyRefunded) >= payment.Amount {
|
||||
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,
|
||||
PaymentID: paymentID,
|
||||
Amount: req.Amount,
|
||||
Status: "completed",
|
||||
Reason: req.Reason,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
bookingID := chi.URLParam(r, "id")
|
||||
if bookingID == "" {
|
||||
http.Error(w, "Booking ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
||||
if !ok || userID == "" {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateTipPaymentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Printf("Failed to decode tip payment request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ValidateAmount(req.Amount); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.CardToken == "" {
|
||||
http.Error(w, "Card token is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
service := NewPaymentService()
|
||||
|
||||
bookingUserID, err := service.GetBookingUserID(r.Context(), bookingID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to get booking user: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if bookingUserID != userID {
|
||||
http.Error(w, "Unauthorized", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
hasCompleted, err := service.HasCompletedPayment(r.Context(), bookingID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to check for completed payments: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if !hasCompleted {
|
||||
http.Error(w, "Booking must have a completed payment before adding tip", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
idempotencyKey := bookingID + "-tip-" + strconv.FormatInt(req.Amount, 10)
|
||||
|
||||
paymentReq := square.CreatePaymentReq{
|
||||
Amount: req.Amount,
|
||||
Currency: "GBP",
|
||||
SourceID: req.CardToken,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
ReferenceID: bookingID,
|
||||
Note: "tip",
|
||||
}
|
||||
|
||||
paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create tip payment: %v", err)
|
||||
http.Error(w, "Payment failed", http.StatusPaymentRequired)
|
||||
return
|
||||
}
|
||||
|
||||
record := PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
PaymentType: "tip",
|
||||
PaymentMethod: "online_square",
|
||||
Status: "completed",
|
||||
Amount: req.Amount,
|
||||
SquarePaymentID: &paymentResult.SquarePayID,
|
||||
IdempotencyKey: &idempotencyKey,
|
||||
Fees: 0,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
CreatedBy: &userID,
|
||||
}
|
||||
|
||||
paymentID, err := service.CreatePaymentRecord(r.Context(), record)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create payment record: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(PaymentResponse{
|
||||
ID: paymentID,
|
||||
BookingID: bookingID,
|
||||
PaymentType: "tip",
|
||||
Status: "completed",
|
||||
Amount: req.Amount,
|
||||
CardBrand: paymentResult.CardBrand,
|
||||
CardLast4: paymentResult.CardLast4,
|
||||
ReceiptURL: paymentResult.ReceiptURL,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func GetBookingPaymentSummary(w http.ResponseWriter, r *http.Request) {
|
||||
bookingID := chi.URLParam(r, "id")
|
||||
if bookingID == "" {
|
||||
http.Error(w, "Booking ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := r.Context().Value(mw.UserIDKey).(string)
|
||||
userRole, _ := r.Context().Value(mw.UserRoleKey).(string)
|
||||
|
||||
service := NewPaymentService()
|
||||
|
||||
if userRole != "admin" && userID != "" {
|
||||
bookingUserID, err := service.GetBookingUserID(r.Context(), bookingID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to get booking user: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if bookingUserID != userID {
|
||||
http.Error(w, "Unauthorized", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
summary, err := service.GetBookingPaymentSummary(r.Context(), bookingID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get payment summary: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
payments := make([]PaymentResponse, len(summary.Payments))
|
||||
for i, p := range summary.Payments {
|
||||
payments[i] = PaymentResponse{
|
||||
ID: p.ID,
|
||||
BookingID: p.BookingID,
|
||||
PaymentType: p.PaymentType,
|
||||
Status: p.Status,
|
||||
Amount: p.Amount,
|
||||
CreatedAt: p.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
refunds := make([]RefundResponse, len(summary.Refunds))
|
||||
for i, rf := range summary.Refunds {
|
||||
refunds[i] = RefundResponse{
|
||||
ID: rf.ID,
|
||||
PaymentID: rf.PaymentID,
|
||||
Amount: rf.Amount,
|
||||
Status: rf.Status,
|
||||
Reason: rf.Reason,
|
||||
CreatedAt: rf.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(PaymentSummaryResponse{
|
||||
TotalAmount: summary.TotalAmount,
|
||||
PaidAmount: summary.PaidAmount,
|
||||
RefundedAmount: summary.RefundedAmount,
|
||||
RemainingAmount: summary.RemainingAmount,
|
||||
Payments: payments,
|
||||
Refunds: refunds,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,853 @@
|
||||
//go:build test && dev
|
||||
// +build test,dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/mw"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
"crussell/testutils/testdb"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
pool, err := testdb.NewPool("")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
testdb.Migrate(&testing.T{}, pool)
|
||||
db.DB = pool
|
||||
jwt.Init()
|
||||
square.Client = square.NewDevClient()
|
||||
SquareClient = square.Client
|
||||
code := m.Run()
|
||||
pool.Close()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func resetTestData(t *testing.T) {
|
||||
t.Helper()
|
||||
testdb.TruncateTables(t, db.DB)
|
||||
}
|
||||
|
||||
func makePaymentRequest(handler http.HandlerFunc, method, path string, body interface{}, token string) *httptest.ResponseRecorder {
|
||||
return makePaymentAuthRequest(handler, method, path, body, token, "")
|
||||
}
|
||||
|
||||
func makePaymentAuthRequest(handler http.HandlerFunc, method, path string, body interface{}, token, userIDOverride string) *httptest.ResponseRecorder {
|
||||
var req *http.Request
|
||||
if body != nil {
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
rctx := chi.NewRouteContext()
|
||||
if id, paramName := extractPaymentIDFromPath(path); id != "" {
|
||||
rctx.URLParams.Add(paramName, id)
|
||||
}
|
||||
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
||||
|
||||
var userID, userRole string
|
||||
if userIDOverride != "" {
|
||||
userID = userIDOverride
|
||||
userRole = "verified_email"
|
||||
} else if token != "" {
|
||||
if info := extractUserFromTestJWT(token); info != nil {
|
||||
userID = info.userID
|
||||
userRole = info.role
|
||||
}
|
||||
}
|
||||
|
||||
if userID != "" {
|
||||
ctx = context.WithValue(ctx, mw.UserIDKey, userID)
|
||||
ctx = context.WithValue(ctx, mw.UserRoleKey, userRole)
|
||||
}
|
||||
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
type paymentUserInfo struct {
|
||||
userID string
|
||||
role string
|
||||
}
|
||||
|
||||
func extractUserFromTestJWT(token string) *paymentUserInfo {
|
||||
parts := splitToken(token)
|
||||
if len(parts) != 3 {
|
||||
return nil
|
||||
}
|
||||
|
||||
decoded, err := base64URLDecode(parts[1])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var claims map[string]interface{}
|
||||
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
userID, _ := claims["user_id"].(string)
|
||||
role, _ := claims["role"].(string)
|
||||
|
||||
if userID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &paymentUserInfo{userID: userID, role: role}
|
||||
}
|
||||
|
||||
func splitToken(token string) []string {
|
||||
var result []string
|
||||
var current []byte
|
||||
for _, c := range token {
|
||||
if c == '.' {
|
||||
result = append(result, string(current))
|
||||
current = nil
|
||||
} else {
|
||||
current = append(current, byte(c))
|
||||
}
|
||||
}
|
||||
if len(current) > 0 {
|
||||
result = append(result, string(current))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func base64URLDecode(s string) ([]byte, error) {
|
||||
return base64.URLEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
func extractPaymentIDFromPath(path string) (string, string) {
|
||||
patterns := []struct {
|
||||
prefix string
|
||||
paramName string
|
||||
}{
|
||||
{"/api/admin/payments/", "payment_id"},
|
||||
{"/api/admin/bookings/", "id"},
|
||||
{"/api/admin/bookings/", "id"},
|
||||
{"/api/user/payment-methods/", "id"},
|
||||
}
|
||||
for _, p := range patterns {
|
||||
if idx := findPaymentLastSegment(path, p.prefix); idx >= 0 {
|
||||
endIdx := len(path)
|
||||
for i := idx; i < len(path); i++ {
|
||||
if path[i] == '/' {
|
||||
endIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
return path[idx:endIdx], p.paramName
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func findPaymentLastSegment(path, prefix string) int {
|
||||
for i := len(path) - 1; i >= len(prefix); i-- {
|
||||
if len(path) > i && path[i-len(prefix):i] == prefix {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func parsePaymentResponseBody(w *httptest.ResponseRecorder, dest interface{}) error {
|
||||
return json.Unmarshal(w.Body.Bytes(), dest)
|
||||
}
|
||||
|
||||
func TestTerminalPayment_HappyPath(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
_, bookingID, _ := setupTestData(t)
|
||||
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
TipEnabled: true,
|
||||
}
|
||||
|
||||
handler := CreateTerminalPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp CheckoutResponse
|
||||
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
||||
t.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if resp.CheckoutID == "" {
|
||||
t.Error("expected checkout ID to be set")
|
||||
}
|
||||
|
||||
if resp.Status != "PENDING" {
|
||||
t.Errorf("expected status PENDING, got %s", resp.Status)
|
||||
}
|
||||
|
||||
var count int
|
||||
err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Errorf("failed to query payments: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 payments (created on completion), got %d", count)
|
||||
}
|
||||
|
||||
_ = userID
|
||||
}
|
||||
|
||||
func setupTestData(t *testing.T) (string, string, string) {
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
|
||||
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test booking: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update booking status: %v", err)
|
||||
}
|
||||
|
||||
return userID, bookingID, serviceID
|
||||
}
|
||||
|
||||
func TestTerminalPayment_PriceOverride(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
_, bookingID, _ := setupTestData(t)
|
||||
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
overrideAmount := int64(3000)
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
OverrideAmount: &overrideAmount,
|
||||
TipEnabled: false,
|
||||
}
|
||||
|
||||
handler := CreateTerminalPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp CheckoutResponse
|
||||
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
||||
t.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalPayment_BookingNotInProgress(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
|
||||
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test booking: %v", err)
|
||||
}
|
||||
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
}
|
||||
|
||||
handler := CreateTerminalPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
_ = serviceID
|
||||
}
|
||||
|
||||
func TestTerminalPayment_BookingNotFound(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
}
|
||||
|
||||
handler := CreateTerminalPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/non-existent/payment", req, adminToken)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlinePayment_NewCard_Deposit(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t)
|
||||
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:test-card-nonce"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
SaveCard: true,
|
||||
IdempotencyKey: "deposit-key-1",
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp PaymentResponse
|
||||
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
||||
t.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if resp.ID == "" {
|
||||
t.Error("expected payment ID to be set")
|
||||
}
|
||||
|
||||
if resp.Status != "completed" {
|
||||
t.Errorf("expected status completed, got %s", resp.Status)
|
||||
}
|
||||
|
||||
if resp.Amount != 2500 {
|
||||
t.Errorf("expected amount 2500, got %d", resp.Amount)
|
||||
}
|
||||
|
||||
var count int
|
||||
err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Errorf("failed to query payments: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 payment, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlinePayment_SavedCard(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t)
|
||||
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(db.DB, userID, "cfa_mock_card_123", "VISA", "4242")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment method: %v", err)
|
||||
}
|
||||
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
CardID: &cardID,
|
||||
IdempotencyKey: "saved-card-key-1",
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp PaymentResponse
|
||||
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
||||
t.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if resp.Status != "completed" {
|
||||
t.Errorf("expected status completed, got %s", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlinePayment_BookingNotOwned(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
_, bookingID, _ := setupTestData(t)
|
||||
|
||||
otherUserID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create other user: %v", err)
|
||||
}
|
||||
|
||||
userToken := jwt.GenerateUserToken(otherUserID)
|
||||
|
||||
cardToken := "cnon:test-card-nonce"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "not-owned-key-1",
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserPaymentMethods_HasCards(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
_, err = fixtures.CreateTestPaymentMethod(db.DB, userID, "cfa_card_1", "VISA", "1111")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment method 1: %v", err)
|
||||
}
|
||||
|
||||
_, err = fixtures.CreateTestPaymentMethod(db.DB, userID, "cfa_card_2", "MASTERCARD", "2222")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment method 2: %v", err)
|
||||
}
|
||||
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
handler := GetUserPaymentMethods
|
||||
w := makePaymentRequest(handler, "GET", "/api/user/payment-methods", nil, userToken)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var cards []SavedCard
|
||||
if err := parsePaymentResponseBody(w, &cards); err != nil {
|
||||
t.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if len(cards) != 2 {
|
||||
t.Errorf("expected 2 cards, got %d", len(cards))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePaymentMethod(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(db.DB, userID, "cfa_card_delete", "VISA", "9999")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment method: %v", err)
|
||||
}
|
||||
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
handler := DeletePaymentMethod
|
||||
w := makePaymentRequest(handler, "DELETE", "/api/user/payment-methods/"+cardID, nil, userToken)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
||||
t.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if resp["status"] != "deleted" {
|
||||
t.Errorf("expected status deleted, got %s", resp["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefund_FullRefund(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
_, bookingID, _ := setupTestData(t)
|
||||
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "in_person_card", "full", "completed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
|
||||
squarePaymentID := "sqp_test_123"
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePaymentID, paymentID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update payment: %v", err)
|
||||
}
|
||||
|
||||
req := RefundRequest{
|
||||
Amount: 5000,
|
||||
Reason: "customer request",
|
||||
}
|
||||
|
||||
handler := RefundPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp RefundResponse
|
||||
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
||||
t.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if resp.Amount != 5000 {
|
||||
t.Errorf("expected amount 5000, got %d", resp.Amount)
|
||||
}
|
||||
|
||||
if resp.Status != "completed" {
|
||||
t.Errorf("expected status completed, got %s", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefund_PartialRefund(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
_, bookingID, _ := setupTestData(t)
|
||||
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "in_person_card", "full", "completed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
|
||||
squarePaymentID := "sqp_test_456"
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePaymentID, paymentID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update payment: %v", err)
|
||||
}
|
||||
|
||||
req := RefundRequest{
|
||||
Amount: 2500,
|
||||
Reason: "partial refund",
|
||||
}
|
||||
|
||||
handler := RefundPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp RefundResponse
|
||||
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
||||
t.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if resp.Amount != 2500 {
|
||||
t.Errorf("expected amount 2500, got %d", resp.Amount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefund_OverRefundRejected(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
_, bookingID, _ := setupTestData(t)
|
||||
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "in_person_card", "full", "completed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
|
||||
squarePaymentID := "sqp_test_789"
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePaymentID, paymentID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update payment: %v", err)
|
||||
}
|
||||
|
||||
req := RefundRequest{
|
||||
Amount: 6000,
|
||||
Reason: "over refund attempt",
|
||||
}
|
||||
|
||||
handler := RefundPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefund_PaymentNotFound(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
req := RefundRequest{
|
||||
Amount: 1000,
|
||||
Reason: "test",
|
||||
}
|
||||
|
||||
handler := RefundPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/payments/non-existent/refund", req, adminToken)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefund_PendingPaymentRejected(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
_, bookingID, _ := setupTestData(t)
|
||||
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "in_person_card", "full", "pending")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
|
||||
req := RefundRequest{
|
||||
Amount: 5000,
|
||||
Reason: "test",
|
||||
}
|
||||
|
||||
handler := RefundPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTipPayment_HappyPath(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t)
|
||||
|
||||
_, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "online_square", "full", "completed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:tip-card"
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: 500,
|
||||
CardToken: cardToken,
|
||||
}
|
||||
|
||||
handler := CreateTipPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp PaymentResponse
|
||||
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
||||
t.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if resp.PaymentType != "tip" {
|
||||
t.Errorf("expected payment type tip, got %s", resp.PaymentType)
|
||||
}
|
||||
|
||||
if resp.Amount != 500 {
|
||||
t.Errorf("expected amount 500, got %d", resp.Amount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTipPayment_NoPriorPayment(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t)
|
||||
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:tip-card"
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: 500,
|
||||
CardToken: cardToken,
|
||||
}
|
||||
|
||||
handler := CreateTipPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdempotency_SameKeyReturnsExisting(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t)
|
||||
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:idempotent-card"
|
||||
idempotencyKey := "idempotent-same-key"
|
||||
|
||||
req1 := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken)
|
||||
|
||||
if w1.Code != http.StatusOK {
|
||||
t.Errorf("first request expected status 200, got %d. body: %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
|
||||
var resp1 PaymentResponse
|
||||
if err := parsePaymentResponseBody(w1, &resp1); err != nil {
|
||||
t.Errorf("failed to parse first response: %v", err)
|
||||
}
|
||||
|
||||
req2 := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
}
|
||||
|
||||
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, w.Body.String())
|
||||
}
|
||||
|
||||
var resp2 PaymentResponse
|
||||
if err := parsePaymentResponseBody(w2, &resp2); err != nil {
|
||||
t.Errorf("failed to parse second response: %v", err)
|
||||
}
|
||||
|
||||
if resp1.ID != resp2.ID {
|
||||
t.Errorf("expected same payment ID, got %s and %s", resp1.ID, resp2.ID)
|
||||
}
|
||||
|
||||
var count int
|
||||
err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Errorf("failed to query payments: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 payment (idempotent), got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdempotency_DifferentKeyCreatesNew(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t)
|
||||
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:different-key-card"
|
||||
|
||||
req1 := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "key-1",
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken)
|
||||
|
||||
if w1.Code != http.StatusOK {
|
||||
t.Errorf("first request expected status 200, got %d. body: %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
|
||||
req2 := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "key-2",
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
var count int
|
||||
err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Errorf("failed to query payments: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 payments (different keys), got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSquareWebhook_DevMode_NoSignature(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/webhooks/square", nil)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
_ = req
|
||||
_ = w
|
||||
t.Skip("webhook handler tested in webhooks package")
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type SavedCard struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
type PaymentService struct{}
|
||||
|
||||
func NewPaymentService() *PaymentService {
|
||||
return &PaymentService{}
|
||||
}
|
||||
|
||||
type PaymentRecord struct {
|
||||
ID string
|
||||
BookingID string
|
||||
PaymentType string
|
||||
PaymentMethod string
|
||||
VendorCode *string
|
||||
InvoiceNumber *int
|
||||
Status string
|
||||
Amount int64
|
||||
IsVATApplicable bool
|
||||
VATRate *float64
|
||||
VATAmount *int64
|
||||
NetAmount *int64
|
||||
UserSavedCardID *string
|
||||
SquarePaymentID *string
|
||||
IdempotencyKey *string
|
||||
Fees int64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CreatedBy *string
|
||||
}
|
||||
|
||||
type RefundRecord struct {
|
||||
ID string
|
||||
PaymentID string
|
||||
BookingID string
|
||||
Amount int64
|
||||
SquareRefundID *string
|
||||
Status string
|
||||
Reason string
|
||||
CreatedBy *string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type PaymentSummary struct {
|
||||
TotalAmount int64
|
||||
PaidAmount int64
|
||||
RefundedAmount int64
|
||||
RemainingAmount int64
|
||||
Payments []PaymentRecord
|
||||
Refunds []RefundRecord
|
||||
}
|
||||
|
||||
func (s *PaymentService) CalculateFees(amount int64, method string) int64 {
|
||||
if method == "online" {
|
||||
return (amount * 14 / 1000) + 25
|
||||
}
|
||||
return (amount * 175 / 10000)
|
||||
}
|
||||
|
||||
func (s *PaymentService) CreatePaymentRecord(ctx context.Context, record PaymentRecord) (string, error) {
|
||||
var id string
|
||||
err := db.DB.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,
|
||||
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||
RETURNING id
|
||||
`,
|
||||
record.BookingID,
|
||||
record.PaymentType,
|
||||
record.PaymentMethod,
|
||||
record.VendorCode,
|
||||
record.InvoiceNumber,
|
||||
record.Status,
|
||||
record.Amount,
|
||||
record.IsVATApplicable,
|
||||
record.VATRate,
|
||||
record.VATAmount,
|
||||
record.NetAmount,
|
||||
record.UserSavedCardID,
|
||||
record.SquarePaymentID,
|
||||
record.IdempotencyKey,
|
||||
record.Fees,
|
||||
record.CreatedAt,
|
||||
record.UpdatedAt,
|
||||
record.CreatedBy,
|
||||
).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) CreateRefundRecord(ctx context.Context, record RefundRecord) (string, error) {
|
||||
var id string
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
INSERT INTO refunds (
|
||||
payment_id, booking_id, amount, square_refund_id, status, reason, created_by, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id
|
||||
`,
|
||||
record.PaymentID,
|
||||
record.BookingID,
|
||||
record.Amount,
|
||||
record.SquareRefundID,
|
||||
record.Status,
|
||||
record.Reason,
|
||||
record.CreatedBy,
|
||||
record.CreatedAt,
|
||||
).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID string) (*PaymentSummary, error) {
|
||||
summary := &PaymentSummary{
|
||||
Payments: []PaymentRecord{},
|
||||
Refunds: []RefundRecord{},
|
||||
}
|
||||
|
||||
var totalAmount int64
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
SELECT COALESCE(SUM(
|
||||
COALESCE(bs.override_price, s.price)
|
||||
), 0)
|
||||
FROM booking_services bs
|
||||
JOIN services s ON bs.service_id = s.id
|
||||
WHERE bs.booking_id = $1
|
||||
`, bookingID).Scan(&totalAmount)
|
||||
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
summary.TotalAmount = totalAmount
|
||||
|
||||
rows, err := db.DB.Query(ctx, `
|
||||
SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number,
|
||||
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
|
||||
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by
|
||||
FROM payments
|
||||
WHERE booking_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`, bookingID)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var paidAmount int64
|
||||
for rows.Next() {
|
||||
var p PaymentRecord
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.BookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber,
|
||||
&p.Status, &p.Amount, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount,
|
||||
&p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees,
|
||||
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary.Payments = append(summary.Payments, p)
|
||||
if p.Status == "completed" {
|
||||
paidAmount += p.Amount
|
||||
}
|
||||
}
|
||||
summary.PaidAmount = paidAmount
|
||||
|
||||
refundRows, err := db.DB.Query(ctx, `
|
||||
SELECT id, payment_id, booking_id, amount, square_refund_id, status, reason, created_by, created_at
|
||||
FROM refunds
|
||||
WHERE booking_id = $1 AND status = 'completed'
|
||||
ORDER BY created_at ASC
|
||||
`, bookingID)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer refundRows.Close()
|
||||
|
||||
var refundedAmount int64
|
||||
for refundRows.Next() {
|
||||
var r RefundRecord
|
||||
err := refundRows.Scan(
|
||||
&r.ID, &r.PaymentID, &r.BookingID, &r.Amount, &r.SquareRefundID,
|
||||
&r.Status, &r.Reason, &r.CreatedBy, &r.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary.Refunds = append(summary.Refunds, r)
|
||||
refundedAmount += r.Amount
|
||||
}
|
||||
summary.RefundedAmount = refundedAmount
|
||||
summary.RemainingAmount = totalAmount - paidAmount + refundedAmount
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) CheckIdempotency(ctx context.Context, bookingID, idempotencyKey string) (*PaymentRecord, error) {
|
||||
var p PaymentRecord
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number,
|
||||
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
|
||||
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by
|
||||
FROM payments
|
||||
WHERE booking_id = $1 AND idempotency_key = $2
|
||||
`, bookingID, idempotencyKey).Scan(
|
||||
&p.ID, &p.BookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber,
|
||||
&p.Status, &p.Amount, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount,
|
||||
&p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees,
|
||||
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) GetPaymentByID(ctx context.Context, paymentID string) (*PaymentRecord, error) {
|
||||
var p PaymentRecord
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number,
|
||||
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
|
||||
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by
|
||||
FROM payments
|
||||
WHERE id = $1
|
||||
`, paymentID).Scan(
|
||||
&p.ID, &p.BookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber,
|
||||
&p.Status, &p.Amount, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount,
|
||||
&p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees,
|
||||
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) GetAlreadyRefundedAmount(ctx context.Context, paymentID string) (int64, error) {
|
||||
var amount int64
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
SELECT COALESCE(SUM(amount), 0) FROM refunds
|
||||
WHERE payment_id = $1 AND status = 'completed'
|
||||
`, paymentID).Scan(&amount)
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return amount, 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, `
|
||||
SELECT COUNT(*) FROM payments
|
||||
WHERE booking_id = $1 AND status = 'completed' AND payment_type IN ('full', 'deposit', 'balance', 'partial')
|
||||
`, bookingID).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) GetBookingStatus(ctx context.Context, bookingID string) (string, error) {
|
||||
var status string
|
||||
err := db.DB.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return status, 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)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) GetUserPaymentMethods(ctx context.Context, userID string) ([]SavedCard, error) {
|
||||
rows, err := db.DB.Query(ctx, `
|
||||
SELECT id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default
|
||||
FROM user_saved_cards
|
||||
WHERE user_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY is_default DESC, created_at DESC
|
||||
`, userID)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var cards []SavedCard
|
||||
for rows.Next() {
|
||||
var c SavedCard
|
||||
err := rows.Scan(&c.ID, &c.SquareCardID, &c.Brand, &c.Last4, &c.ExpMonth, &c.ExpYear, &c.Fingerprint, &c.IsDefault)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cards = append(cards, c)
|
||||
}
|
||||
|
||||
if cards == nil {
|
||||
cards = []SavedCard{}
|
||||
}
|
||||
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) DeletePaymentMethod(ctx context.Context, cardID, userID string) error {
|
||||
retainedUntil := time.Now().Add(7 * 365 * 24 * time.Hour)
|
||||
_, err := db.DB.Exec(ctx, `
|
||||
UPDATE user_saved_cards
|
||||
SET deleted_at = NOW(), deleted_by = $1, retained_until = $2
|
||||
WHERE id = $3 AND user_id = $1
|
||||
`, userID, retainedUntil, cardID)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PaymentService) SaveCardForUser(ctx context.Context, userID, squareCardID, brand, last4 string, expMonth, expYear int, fingerprint string) (string, error) {
|
||||
var id string
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
INSERT INTO user_saved_cards (
|
||||
user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, false, NOW())
|
||||
RETURNING id
|
||||
`, userID, squareCardID, brand, last4, expMonth, expYear, fingerprint).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) GetCardByID(ctx context.Context, cardID, userID string) (*SavedCard, error) {
|
||||
var c SavedCard
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
SELECT id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default
|
||||
FROM user_saved_cards
|
||||
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
|
||||
`, cardID, userID).Scan(&c.ID, &c.SquareCardID, &c.Brand, &c.Last4, &c.ExpMonth, &c.ExpYear, &c.Fingerprint, &c.IsDefault)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
var SquareClient square.SquareClient
|
||||
@@ -0,0 +1,44 @@
|
||||
package payments
|
||||
|
||||
import "errors"
|
||||
|
||||
// Valid payment types
|
||||
var validPaymentTypes = map[string]bool{
|
||||
"deposit": true,
|
||||
"full": true,
|
||||
"tip": true,
|
||||
"balance": true,
|
||||
"partial": true,
|
||||
}
|
||||
|
||||
// ValidateAmount checks that amount is greater than 0
|
||||
func ValidateAmount(amount int64) error {
|
||||
if amount <= 0 {
|
||||
return errors.New("amount must be greater than 0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatePaymentType checks that payment type is valid
|
||||
func ValidatePaymentType(pt string) error {
|
||||
if !validPaymentTypes[pt] {
|
||||
return errors.New("invalid payment type")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateRefundReason checks that refund reason is not empty
|
||||
func ValidateRefundReason(reason string) error {
|
||||
if reason == "" {
|
||||
return errors.New("refund reason is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateCardInfo checks that at least one of cardID or newCardToken is provided
|
||||
func ValidateCardInfo(cardID, newCardToken *string) error {
|
||||
if cardID == nil && (newCardToken == nil || *newCardToken == "") {
|
||||
return errors.New("either card_id or new_card_token is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
type SquareWebhookEvent struct {
|
||||
Type string `json:"type"`
|
||||
EventID string `json:"event_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
LocationID string `json:"location_id"`
|
||||
}
|
||||
|
||||
func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read webhook body: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
signature := r.Header.Get("x-square-signature")
|
||||
signingKey := os.Getenv("SQUARE_WEBHOOK_SIGNATURE_KEY")
|
||||
|
||||
if signingKey != "" && signature != "" {
|
||||
if !verifySquareSignature(body, signature, signingKey) {
|
||||
log.Printf("Invalid Square webhook signature")
|
||||
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var event SquareWebhookEvent
|
||||
if err := json.Unmarshal(body, &event); err != nil {
|
||||
log.Printf("Failed to parse webhook event: %v", err)
|
||||
http.Error(w, "Invalid event", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[SQUARE-WEBHOOK] Received event: %s", event.Type)
|
||||
|
||||
switch event.Type {
|
||||
case "payment.updated":
|
||||
handlePaymentUpdated(event.Data)
|
||||
case "refund.updated":
|
||||
handleRefundUpdated(event.Data)
|
||||
case "dispute.created":
|
||||
log.Printf("[SQUARE-WEBHOOK] Dispute created: %s", event.EventID)
|
||||
default:
|
||||
log.Printf("[SQUARE-WEBHOOK] Unknown event type: %s", event.Type)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
func verifySquareSignature(body []byte, signature, signingKey string) bool {
|
||||
mac := hmac.New(sha256.New, []byte(signingKey))
|
||||
mac.Write(body)
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
return hmac.Equal([]byte(signature), []byte(expected))
|
||||
}
|
||||
|
||||
func handlePaymentUpdated(data json.RawMessage) {
|
||||
log.Printf("[SQUARE-WEBHOOK] payment.updated: %s", string(data))
|
||||
}
|
||||
|
||||
func handleRefundUpdated(data json.RawMessage) {
|
||||
log.Printf("[SQUARE-WEBHOOK] refund.updated: %s", string(data))
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//go:build !dev
|
||||
// +build !dev
|
||||
|
||||
package square
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var Client SquareClient
|
||||
|
||||
type ProdClient struct{}
|
||||
|
||||
func NewClient() SquareClient {
|
||||
return NewProdClient()
|
||||
}
|
||||
|
||||
func NewProdClient() SquareClient {
|
||||
return &ProdClient{}
|
||||
}
|
||||
|
||||
func (p *ProdClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
||||
return nil, errors.New("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
}
|
||||
|
||||
func (p *ProdClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error) {
|
||||
return nil, errors.New("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
}
|
||||
|
||||
func (p *ProdClient) GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error) {
|
||||
return nil, errors.New("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
}
|
||||
|
||||
func (p *ProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
||||
return nil, errors.New("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
}
|
||||
|
||||
func (p *ProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
|
||||
return nil, errors.New("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
}
|
||||
|
||||
func (p *ProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) {
|
||||
return nil, errors.New("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
}
|
||||
|
||||
func (p *ProdClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
|
||||
return errors.New("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//go:build dev
|
||||
// +build dev
|
||||
|
||||
package square
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var Client SquareClient
|
||||
|
||||
type MockClient struct {
|
||||
mu sync.RWMutex
|
||||
cards map[string]map[string]*CardOnFile
|
||||
checkouts map[string]*CheckoutResult
|
||||
payments map[string]*PaymentResult
|
||||
refunds map[string]*RefundResult
|
||||
completed map[string]*PaymentResult
|
||||
}
|
||||
|
||||
type devProdClient struct{}
|
||||
|
||||
func (d *devProdClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
||||
return nil, fmt.Errorf("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
}
|
||||
func (d *devProdClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error) {
|
||||
return nil, fmt.Errorf("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
}
|
||||
func (d *devProdClient) GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error) {
|
||||
return nil, fmt.Errorf("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
}
|
||||
func (d *devProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
||||
return nil, fmt.Errorf("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
}
|
||||
func (d *devProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
|
||||
return nil, fmt.Errorf("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
}
|
||||
func (d *devProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) {
|
||||
return nil, fmt.Errorf("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
}
|
||||
func (d *devProdClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
|
||||
return fmt.Errorf("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
}
|
||||
|
||||
func NewClient() SquareClient {
|
||||
return NewDevClient()
|
||||
}
|
||||
|
||||
func NewDevClient() SquareClient {
|
||||
env := os.Getenv("SQUARE_ENVIRONMENT")
|
||||
if env == "sandbox" || env == "production" {
|
||||
log.Printf("[SQUARE-MOCK] SQUARE_ENVIRONMENT=%s — real client TODO stub", env)
|
||||
return &devProdClient{}
|
||||
}
|
||||
log.Println("[SQUARE-MOCK] Using in-memory mock client")
|
||||
return &MockClient{
|
||||
cards: make(map[string]map[string]*CardOnFile),
|
||||
checkouts: make(map[string]*CheckoutResult),
|
||||
payments: make(map[string]*PaymentResult),
|
||||
refunds: make(map[string]*RefundResult),
|
||||
completed: make(map[string]*PaymentResult),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
||||
log.Printf("[SQUARE-MOCK] CreatePayment: amount=%d, reference=%s", req.Amount, req.ReferenceID)
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
paymentID := fmt.Sprintf("pay_mock_%d", time.Now().UnixNano())
|
||||
fees := req.Amount*14/1000 + 25 // online rate: 1.4% + 25p
|
||||
|
||||
result := &PaymentResult{
|
||||
ID: paymentID,
|
||||
Status: "COMPLETED",
|
||||
Amount: req.Amount,
|
||||
CardBrand: "VISA",
|
||||
CardLast4: "4242",
|
||||
TipAmount: 0,
|
||||
ReceiptURL: "https://squareup.com/receipt/" + paymentID,
|
||||
SquarePayID: "sqp_" + paymentID,
|
||||
Fees: fees,
|
||||
}
|
||||
m.payments[paymentID] = result
|
||||
log.Printf("[SQUARE-MOCK] Payment completed: id=%s, fees=%d", paymentID, fees)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error) {
|
||||
log.Printf("[SQUARE-MOCK] CreateCheckout: amount=%d, tipEnabled=%v, reference=%s", req.Amount, req.TipEnabled, req.ReferenceID)
|
||||
|
||||
checkoutID := fmt.Sprintf("chk_mock_%d", time.Now().UnixNano())
|
||||
result := &CheckoutResult{
|
||||
ID: checkoutID,
|
||||
Status: "PENDING",
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.checkouts[checkoutID] = result
|
||||
m.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
paymentID := fmt.Sprintf("pay_%d", time.Now().UnixNano())
|
||||
amount := req.Amount
|
||||
tipAmount := int64(0)
|
||||
if req.TipEnabled {
|
||||
tipAmount = 500
|
||||
amount += tipAmount
|
||||
}
|
||||
fees := amount*175/10000 // in-person rate: 1.75%
|
||||
|
||||
paymentResult := &PaymentResult{
|
||||
ID: paymentID,
|
||||
Status: "COMPLETED",
|
||||
Amount: amount,
|
||||
CardBrand: "VISA",
|
||||
CardLast4: "4242",
|
||||
TipAmount: tipAmount,
|
||||
ReceiptURL: "https://squareup.com/receipt/" + paymentID,
|
||||
SquarePayID: "sqp_" + paymentID,
|
||||
Fees: fees,
|
||||
}
|
||||
m.completed[checkoutID] = paymentResult
|
||||
m.checkouts[checkoutID].Status = "COMPLETED"
|
||||
log.Printf("[SQUARE-MOCK] Checkout completed: id=%s, amount=%d, tip=%d", checkoutID, amount, tipAmount)
|
||||
}()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockClient) GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error) {
|
||||
log.Printf("[SQUARE-MOCK] GetCheckout: id=%s", checkoutID)
|
||||
|
||||
m.mu.RLock()
|
||||
checkout, ok := m.checkouts[checkoutID]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("checkout not found: %s", checkoutID)
|
||||
}
|
||||
|
||||
if checkout.Status == "PENDING" {
|
||||
return nil, fmt.Errorf("checkout pending")
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
result, ok := m.completed[checkoutID]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("checkout result not found: %s", checkoutID)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
||||
log.Printf("[SQUARE-MOCK] RefundPayment: payment=%s, amount=%d", req.PaymentID, req.Amount)
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
refundID := fmt.Sprintf("ref_mock_%d", time.Now().UnixNano())
|
||||
amount := req.Amount
|
||||
if amount == 0 {
|
||||
if payment, ok := m.payments[req.PaymentID]; ok {
|
||||
amount = payment.Amount
|
||||
}
|
||||
}
|
||||
|
||||
result := &RefundResult{
|
||||
ID: refundID,
|
||||
Status: "COMPLETED",
|
||||
Amount: amount,
|
||||
}
|
||||
m.refunds[refundID] = result
|
||||
log.Printf("[SQUARE-MOCK] Refund completed: id=%s, amount=%d", refundID, amount)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
|
||||
log.Printf("[SQUARE-MOCK] CreateCardOnFile: user=%s", userID)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.cards[userID] == nil {
|
||||
m.cards[userID] = make(map[string]*CardOnFile)
|
||||
}
|
||||
|
||||
cardID := fmt.Sprintf("mock_card_%d", time.Now().UnixNano())
|
||||
card := &CardOnFile{
|
||||
ID: cardID,
|
||||
CardID: "cfa_" + cardID,
|
||||
Brand: "VISA",
|
||||
Last4: "4242",
|
||||
ExpMonth: 12,
|
||||
ExpYear: 2030,
|
||||
Fingerprint: fmt.Sprintf("fp_%d", time.Now().UnixNano()),
|
||||
IsDefault: len(m.cards[userID]) == 0,
|
||||
}
|
||||
m.cards[userID][cardID] = card
|
||||
log.Printf("[SQUARE-MOCK] Card created: id=%s, brand=%s, last4=%s", cardID, card.Brand, card.Last4)
|
||||
return card, nil
|
||||
}
|
||||
|
||||
func (m *MockClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) {
|
||||
log.Printf("[SQUARE-MOCK] GetCardsOnFile: user=%s", userID)
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
userCards, ok := m.cards[userID]
|
||||
if !ok {
|
||||
return []CardOnFile{}, nil
|
||||
}
|
||||
|
||||
var cards []CardOnFile
|
||||
for _, card := range userCards {
|
||||
cards = append(cards, *card)
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
func (m *MockClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
|
||||
log.Printf("[SQUARE-MOCK] DeleteCardOnFile: id=%s", cardID)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for userID, cards := range m.cards {
|
||||
if _, ok := cards[cardID]; ok {
|
||||
delete(m.cards[userID], cardID)
|
||||
log.Printf("[SQUARE-MOCK] Card deleted: id=%s (user=%s)", cardID, userID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("card not found: %s", cardID)
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
//go:build test && dev
|
||||
// +build test,dev
|
||||
|
||||
package square
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDevClient_CreatePayment_ReturnsCompleted(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
|
||||
ctx := context.Background()
|
||||
req := CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "test-key-1",
|
||||
ReferenceID: "booking-123",
|
||||
Note: "full",
|
||||
}
|
||||
|
||||
result, err := client.CreatePayment(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePayment failed: %v", err)
|
||||
}
|
||||
|
||||
if result.Status != "COMPLETED" {
|
||||
t.Errorf("expected status COMPLETED, got %s", result.Status)
|
||||
}
|
||||
|
||||
if result.Amount != 5000 {
|
||||
t.Errorf("expected amount 5000, got %d", result.Amount)
|
||||
}
|
||||
|
||||
if result.CardBrand != "VISA" {
|
||||
t.Errorf("expected card brand VISA, got %s", result.CardBrand)
|
||||
}
|
||||
|
||||
if result.CardLast4 != "4242" {
|
||||
t.Errorf("expected last4 4242, got %s", result.CardLast4)
|
||||
}
|
||||
|
||||
if result.Fees == 0 {
|
||||
t.Error("expected fees to be calculated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevClient_CreateCheckout_PendingThenCompleted(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
|
||||
ctx := context.Background()
|
||||
req := CreateCheckoutReq{
|
||||
Amount: 7500,
|
||||
Currency: "GBP",
|
||||
IdempotencyKey: "checkout-key-1",
|
||||
ReferenceID: "booking-456",
|
||||
TipEnabled: true,
|
||||
}
|
||||
|
||||
result, err := client.CreateCheckout(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCheckout failed: %v", err)
|
||||
}
|
||||
|
||||
if result.Status != "PENDING" {
|
||||
t.Errorf("expected status PENDING, got %s", result.Status)
|
||||
}
|
||||
|
||||
if result.ID == "" {
|
||||
t.Error("expected checkout ID to be set")
|
||||
}
|
||||
|
||||
time.Sleep(4 * time.Second)
|
||||
|
||||
completed, err := client.GetCheckout(ctx, result.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCheckout failed: %v", err)
|
||||
}
|
||||
|
||||
if completed.Status != "COMPLETED" {
|
||||
t.Errorf("expected status COMPLETED after wait, got %s", completed.Status)
|
||||
}
|
||||
|
||||
if completed.Amount != 8000 {
|
||||
t.Errorf("expected amount 8000 (7500 + 500 tip), got %d", completed.Amount)
|
||||
}
|
||||
|
||||
if completed.TipAmount != 500 {
|
||||
t.Errorf("expected tip 500, got %d", completed.TipAmount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevClient_CreateCheckout_NoTip(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
|
||||
ctx := context.Background()
|
||||
req := CreateCheckoutReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
IdempotencyKey: "checkout-key-notip",
|
||||
ReferenceID: "booking-789",
|
||||
TipEnabled: false,
|
||||
}
|
||||
|
||||
result, err := client.CreateCheckout(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCheckout failed: %v", err)
|
||||
}
|
||||
|
||||
if result.Status != "PENDING" {
|
||||
t.Errorf("expected status PENDING, got %s", result.Status)
|
||||
}
|
||||
|
||||
time.Sleep(4 * time.Second)
|
||||
|
||||
completed, err := client.GetCheckout(ctx, result.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCheckout failed: %v", err)
|
||||
}
|
||||
|
||||
if completed.Amount != 5000 {
|
||||
t.Errorf("expected amount 5000 (no tip), got %d", completed.Amount)
|
||||
}
|
||||
|
||||
if completed.TipAmount != 0 {
|
||||
t.Errorf("expected tip 0, got %d", completed.TipAmount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevClient_RefundPayment_ReturnsCompleted(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
paymentReq := CreatePaymentReq{
|
||||
Amount: 10000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "payment-for-refund",
|
||||
ReferenceID: "booking-refund",
|
||||
Note: "full",
|
||||
}
|
||||
|
||||
paymentResult, err := client.CreatePayment(ctx, paymentReq)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePayment failed: %v", err)
|
||||
}
|
||||
|
||||
refundReq := RefundPaymentReq{
|
||||
PaymentID: paymentResult.ID,
|
||||
Amount: 5000,
|
||||
IdempotencyKey: "refund-key-1",
|
||||
Reason: "customer request",
|
||||
}
|
||||
|
||||
refundResult, err := client.RefundPayment(ctx, refundReq)
|
||||
if err != nil {
|
||||
t.Fatalf("RefundPayment failed: %v", err)
|
||||
}
|
||||
|
||||
if refundResult.Status != "COMPLETED" {
|
||||
t.Errorf("expected status COMPLETED, got %s", refundResult.Status)
|
||||
}
|
||||
|
||||
if refundResult.Amount != 5000 {
|
||||
t.Errorf("expected amount 5000, got %d", refundResult.Amount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevClient_CardOnFile_CreateAndGet(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
|
||||
ctx := context.Background()
|
||||
userID := "user-test-123"
|
||||
|
||||
card, err := client.CreateCardOnFile(ctx, userID, "cnon:test-token")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCardOnFile failed: %v", err)
|
||||
}
|
||||
|
||||
if card.ID == "" {
|
||||
t.Error("expected card ID to be set")
|
||||
}
|
||||
|
||||
if card.Brand != "VISA" {
|
||||
t.Errorf("expected brand VISA, got %s", card.Brand)
|
||||
}
|
||||
|
||||
if card.Last4 != "4242" {
|
||||
t.Errorf("expected last4 4242, got %s", card.Last4)
|
||||
}
|
||||
|
||||
if !card.IsDefault {
|
||||
t.Error("expected first card to be default")
|
||||
}
|
||||
|
||||
cards, err := client.GetCardsOnFile(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCardsOnFile failed: %v", err)
|
||||
}
|
||||
|
||||
if len(cards) != 1 {
|
||||
t.Errorf("expected 1 card, got %d", len(cards))
|
||||
}
|
||||
|
||||
if cards[0].ID != card.ID {
|
||||
t.Errorf("expected card ID %s, got %s", card.ID, cards[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevClient_CardOnFile_MultipleCards(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
|
||||
ctx := context.Background()
|
||||
userID := "user-test-multiple"
|
||||
|
||||
card1, err := client.CreateCardOnFile(ctx, userID, "cnon:token-1")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCardOnFile failed: %v", err)
|
||||
}
|
||||
|
||||
card2, err := client.CreateCardOnFile(ctx, userID, "cnon:token-2")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCardOnFile failed: %v", err)
|
||||
}
|
||||
|
||||
cards, err := client.GetCardsOnFile(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCardsOnFile failed: %v", err)
|
||||
}
|
||||
|
||||
if len(cards) != 2 {
|
||||
t.Errorf("expected 2 cards, got %d", len(cards))
|
||||
}
|
||||
|
||||
if !card1.IsDefault {
|
||||
t.Error("first card should be default")
|
||||
}
|
||||
|
||||
if card2.IsDefault {
|
||||
t.Error("second card should not be default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevClient_CardOnFile_Delete(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
|
||||
ctx := context.Background()
|
||||
userID := "user-test-delete"
|
||||
|
||||
card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-delete")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCardOnFile failed: %v", err)
|
||||
}
|
||||
|
||||
err = client.DeleteCardOnFile(ctx, card.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteCardOnFile failed: %v", err)
|
||||
}
|
||||
|
||||
cards, err := client.GetCardsOnFile(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCardsOnFile failed: %v", err)
|
||||
}
|
||||
|
||||
if len(cards) != 0 {
|
||||
t.Errorf("expected 0 cards after delete, got %d", len(cards))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevClient_CardOnFile_DeleteNotFound(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.DeleteCardOnFile(ctx, "non-existent-card")
|
||||
if err == nil {
|
||||
t.Error("expected error when deleting non-existent card")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevClient_GetCheckout_NotFound(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.GetCheckout(ctx, "non-existent-checkout")
|
||||
if err == nil {
|
||||
t.Error("expected error when checkout not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevClient_ConcurrentPayments(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
|
||||
ctx := context.Background()
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan *PaymentResult, 10)
|
||||
errors := make(chan error, 10)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
|
||||
req := CreatePaymentReq{
|
||||
Amount: int64(1000 + idx*100),
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "concurrent-key-" + string(rune('0'+idx)),
|
||||
ReferenceID: "booking-concurrent",
|
||||
Note: "full",
|
||||
}
|
||||
|
||||
result, err := client.CreatePayment(ctx, req)
|
||||
if err != nil {
|
||||
errors <- err
|
||||
return
|
||||
}
|
||||
results <- result
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(results)
|
||||
close(errors)
|
||||
|
||||
errorCount := 0
|
||||
for err := range errors {
|
||||
t.Logf("Concurrent payment error: %v", err)
|
||||
errorCount++
|
||||
}
|
||||
|
||||
if errorCount > 0 {
|
||||
t.Errorf("expected no errors, got %d", errorCount)
|
||||
}
|
||||
|
||||
resultCount := 0
|
||||
for result := range results {
|
||||
if result.Status != "COMPLETED" {
|
||||
t.Errorf("expected status COMPLETED, got %s", result.Status)
|
||||
}
|
||||
resultCount++
|
||||
}
|
||||
|
||||
if resultCount != 10 {
|
||||
t.Errorf("expected 10 results, got %d", resultCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package square
|
||||
|
||||
import "context"
|
||||
|
||||
type CreatePaymentReq struct {
|
||||
Amount int64 // in pence (GBP cents)
|
||||
Currency string // "GBP"
|
||||
SourceID string // card token or "cnon:xxx" nonce
|
||||
IdempotencyKey string
|
||||
ReferenceID string // booking ID
|
||||
Note string
|
||||
}
|
||||
|
||||
type CreateCheckoutReq struct {
|
||||
Amount int64
|
||||
Currency string
|
||||
IdempotencyKey string
|
||||
ReferenceID string
|
||||
TipEnabled bool
|
||||
}
|
||||
|
||||
type RefundPaymentReq struct {
|
||||
PaymentID string
|
||||
Amount int64 // in pence, 0 = full refund
|
||||
IdempotencyKey string
|
||||
Reason string
|
||||
}
|
||||
|
||||
type PaymentResult struct {
|
||||
ID string
|
||||
Status string // "COMPLETED", "FAILED", "PENDING"
|
||||
Amount int64
|
||||
CardBrand string
|
||||
CardLast4 string
|
||||
TipAmount int64
|
||||
ReceiptURL string
|
||||
SquarePayID string // Square's payment ID
|
||||
Fees int64 // processing fee in pence
|
||||
}
|
||||
|
||||
type CheckoutResult struct {
|
||||
ID string
|
||||
Status string // "PENDING", "COMPLETED", "FAILED"
|
||||
}
|
||||
|
||||
type CardOnFile struct {
|
||||
ID string
|
||||
CardID string // Square's card-on-file token
|
||||
Brand string
|
||||
Last4 string
|
||||
ExpMonth int
|
||||
ExpYear int
|
||||
Fingerprint string
|
||||
IsDefault bool
|
||||
}
|
||||
|
||||
type RefundResult struct {
|
||||
ID string
|
||||
Status string
|
||||
Amount int64
|
||||
}
|
||||
|
||||
type SquareClient interface {
|
||||
CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error)
|
||||
CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error)
|
||||
GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error)
|
||||
RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error)
|
||||
CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error)
|
||||
GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error)
|
||||
DeleteCardOnFile(ctx context.Context, cardID string) error
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crussell/auth"
|
||||
"crussell/internal/dav"
|
||||
"crussell/internal/s3"
|
||||
"crussell/internal/square"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -24,11 +25,13 @@ import (
|
||||
"crussell/handlers/admin"
|
||||
"crussell/handlers/bookings"
|
||||
"crussell/handlers/notifications"
|
||||
"crussell/handlers/payments"
|
||||
"crussell/handlers/portfolio"
|
||||
"crussell/handlers/scheduling"
|
||||
"crussell/handlers/services"
|
||||
"crussell/handlers/today"
|
||||
"crussell/handlers/user"
|
||||
"crussell/handlers/webhooks"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -61,6 +64,11 @@ func initS3() {
|
||||
}
|
||||
}
|
||||
|
||||
func initSquare() {
|
||||
payments.SquareClient = square.NewClient()
|
||||
fmt.Println("Square client initialized (dev mock)")
|
||||
}
|
||||
|
||||
func healthCheckHandler(w http.ResponseWriter, r *http.Request) {
|
||||
status := "ok"
|
||||
services := map[string]string{
|
||||
@@ -101,6 +109,7 @@ func main() {
|
||||
initDB()
|
||||
initDav()
|
||||
initS3()
|
||||
initSquare()
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -218,6 +227,13 @@ func main() {
|
||||
r.Delete("/bookings/{id}", bookings.DeleteBookingHandler)
|
||||
r.Post("/bookings/{id}/edit-request", bookings.RequestEditHandler)
|
||||
r.Delete("/bookings/{id}/edit-request", bookings.DeleteEditRequestHandler)
|
||||
|
||||
// User payment routes
|
||||
r.Post("/bookings/{id}/payment", payments.CreateBookingPayment)
|
||||
r.Get("/user/payment-methods", payments.GetUserPaymentMethods)
|
||||
r.Delete("/user/payment-methods/{id}", payments.DeletePaymentMethod)
|
||||
r.Post("/bookings/{id}/tip", payments.CreateTipPayment)
|
||||
r.Get("/bookings/{id}/payment-summary", payments.GetBookingPaymentSummary)
|
||||
})
|
||||
|
||||
// Admin-only (no rate limit - trusted users with authenticated sessions)
|
||||
@@ -283,9 +299,17 @@ r.Route("/admin/users", func(r chi.Router) {
|
||||
r.Delete("/{id}", admin.DeleteDiscountCampaign)
|
||||
r.Get("/{id}/stats", admin.GetCampaignStats)
|
||||
})
|
||||
|
||||
// Admin payment routes
|
||||
r.Post("/admin/bookings/{id}/payment", payments.CreateTerminalPayment)
|
||||
r.Get("/admin/payments/{checkout_id}/status", payments.GetCheckoutStatus)
|
||||
r.Post("/admin/payments/{payment_id}/refund", payments.RefundPayment)
|
||||
})
|
||||
})
|
||||
|
||||
// Webhooks (no auth - Square sends to base path)
|
||||
r.Post("/webhooks/square", webhooks.HandleSquareWebhook)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":8080",
|
||||
Handler: r,
|
||||
|
||||
@@ -228,3 +228,78 @@ func DeleteTimeBlocker(pool *pgxpool.Pool, blockerID string) error {
|
||||
_, err := pool.Exec(ctx, "DELETE FROM time_blockers WHERE id = $1", blockerID)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateTestPayment creates a payment record for testing
|
||||
// Returns payment ID
|
||||
func CreateTestPayment(db *pgxpool.Pool, bookingID string, amount float64, method string, ptype string, status string) (string, error) {
|
||||
ctx := context.Background()
|
||||
var paymentID string
|
||||
err := db.QueryRow(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
|
||||
RETURNING id
|
||||
`, bookingID, ptype, method, status, amount).Scan(&paymentID)
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create payment: %w", err)
|
||||
}
|
||||
|
||||
return paymentID, nil
|
||||
}
|
||||
|
||||
// CreateTestRefund creates a refund record for testing
|
||||
// Returns refund ID
|
||||
func CreateTestRefund(db *pgxpool.Pool, paymentID string, bookingID string, amount float64) (string, error) {
|
||||
ctx := context.Background()
|
||||
var refundID string
|
||||
err := db.QueryRow(ctx, `
|
||||
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at)
|
||||
VALUES ($1, $2, $3, 'completed', 'test refund', NOW())
|
||||
RETURNING id
|
||||
`, paymentID, bookingID, amount).Scan(&refundID)
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create refund: %w", err)
|
||||
}
|
||||
|
||||
return refundID, nil
|
||||
}
|
||||
|
||||
// CreateTestPaymentMethod creates a saved card for a user
|
||||
// Returns card ID
|
||||
func CreateTestPaymentMethod(db *pgxpool.Pool, userID string, squareCardID string, brand string, last4 string) (string, error) {
|
||||
ctx := context.Background()
|
||||
var cardID string
|
||||
err := db.QueryRow(ctx, `
|
||||
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default, created_at)
|
||||
VALUES ($1, $2, $3, $4, 12, 2030, 'test_fp', false, NOW())
|
||||
RETURNING id
|
||||
`, userID, squareCardID, brand, last4).Scan(&cardID)
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create payment method: %w", err)
|
||||
}
|
||||
|
||||
return cardID, nil
|
||||
}
|
||||
|
||||
// DeletePayment deletes a payment from the database
|
||||
func DeletePayment(pool *pgxpool.Pool, paymentID string) error {
|
||||
ctx := context.Background()
|
||||
_, err := pool.Exec(ctx, "DELETE FROM payments WHERE id = $1", paymentID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteRefund deletes a refund from the database
|
||||
func DeleteRefund(pool *pgxpool.Pool, refundID string) error {
|
||||
ctx := context.Background()
|
||||
_, err := pool.Exec(ctx, "DELETE FROM refunds WHERE id = $1", refundID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeletePaymentMethod deletes a saved card from the database
|
||||
func DeletePaymentMethod(pool *pgxpool.Pool, cardID string) error {
|
||||
ctx := context.Background()
|
||||
_, err := pool.Exec(ctx, "DELETE FROM user_saved_cards WHERE id = $1", cardID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -99,7 +99,11 @@ func Migrate(t *testing.T, pool *pgxpool.Pool) {
|
||||
"user_notification_preferences",
|
||||
"user_referrals",
|
||||
"booking_services",
|
||||
"refunds",
|
||||
"payments",
|
||||
"user_saved_cards",
|
||||
"square_deposits",
|
||||
"affiliate_payouts",
|
||||
"bookings",
|
||||
"user_patch_tests",
|
||||
"patch_tests",
|
||||
@@ -202,7 +206,11 @@ func TruncateTables(t *testing.T, pool *pgxpool.Pool) {
|
||||
"user_social_logins",
|
||||
"verification_codes",
|
||||
"booking_services",
|
||||
"refunds",
|
||||
"payments",
|
||||
"user_saved_cards",
|
||||
"square_deposits",
|
||||
"affiliate_payouts",
|
||||
"bookings",
|
||||
"booking_edit_requests",
|
||||
"user_patch_tests",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import * as Textarea from '$lib/components/ui/textarea';
|
||||
import * as Label from '$lib/components/ui/label';
|
||||
import DatePicker from '$lib/components/booking/DatePicker.svelte';
|
||||
import PaymentModal from '$lib/components/payments/PaymentModal.svelte';
|
||||
import type { Booking, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
|
||||
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
|
||||
|
||||
@@ -94,6 +95,25 @@
|
||||
.reduce((sum, p) => sum + p.amount, 0) || 0
|
||||
);
|
||||
|
||||
let depositOutstanding = $derived(
|
||||
selectedBooking?.deposit_required && !selectedBooking?.deposit_paid
|
||||
);
|
||||
|
||||
let canPayEarly = $derived(
|
||||
selectedBooking &&
|
||||
!depositOutstanding &&
|
||||
totalPaid < selectedBooking.total_amount &&
|
||||
['confirmed', 'pending'].includes(selectedBooking.status)
|
||||
);
|
||||
|
||||
let showPaymentModal = $state(false);
|
||||
|
||||
function handlePaymentComplete() {
|
||||
toast.success('Payment completed');
|
||||
showPaymentModal = false;
|
||||
fetchBookingDetails();
|
||||
}
|
||||
|
||||
let isRescheduleValid = $derived(
|
||||
rescheduleDate && rescheduleTime && rescheduleTime.length >= 4
|
||||
);
|
||||
@@ -847,12 +867,37 @@
|
||||
Add to Calendar
|
||||
</Button>
|
||||
{/if}
|
||||
<Button size="sm" class="flex-1" onclick={() => (open = false)}>Close</Button>
|
||||
{#if depositOutstanding}
|
||||
<Button
|
||||
size="sm"
|
||||
class="flex-1 bg-amber-600 hover:bg-amber-700 text-white"
|
||||
onclick={() => (showPaymentModal = true)}
|
||||
>
|
||||
Pay Deposit
|
||||
</Button>
|
||||
{:else if canPayEarly}
|
||||
<Button
|
||||
size="sm"
|
||||
class="flex-1 bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
onclick={() => (showPaymentModal = true)}
|
||||
>
|
||||
Pay Early
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<Button size="sm" class="w-full" variant="ghost" onclick={() => (open = false)}>Close</Button>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
{#if showPaymentModal && selectedBooking}
|
||||
<PaymentModal
|
||||
booking={selectedBooking}
|
||||
onClose={() => (showPaymentModal = false)}
|
||||
onComplete={handlePaymentComplete}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Modal.Root open={showCancelConfirm} onOpenChange={(v) => (showCancelConfirm = v)}>
|
||||
<Modal.Content class="max-w-sm">
|
||||
<Modal.Header>
|
||||
|
||||
@@ -39,6 +39,13 @@
|
||||
let availableServices = $state<Service[]>([]);
|
||||
let loadingServices = $state(false);
|
||||
|
||||
// Refund dialog state
|
||||
let showRefundModal = $state(false);
|
||||
let refundPaymentId = $state('');
|
||||
let refundAmount = $state('');
|
||||
let refundReason = $state('');
|
||||
let refundLoading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open && bookingId) {
|
||||
fetchBooking();
|
||||
@@ -331,6 +338,55 @@
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openRefundModal(paymentId: string, amountPence: number) {
|
||||
refundPaymentId = paymentId;
|
||||
refundAmount = (amountPence / 100).toFixed(2);
|
||||
refundReason = '';
|
||||
showRefundModal = true;
|
||||
}
|
||||
|
||||
async function processRefund() {
|
||||
if (!refundAmount || !refundReason.trim()) {
|
||||
toast.error('Please enter a refund amount and reason');
|
||||
return;
|
||||
}
|
||||
|
||||
refundLoading = true;
|
||||
try {
|
||||
const amountPence = Math.round(parseFloat(refundAmount) * 100);
|
||||
if (isNaN(amountPence) || amountPence <= 0) {
|
||||
toast.error('Invalid refund amount');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/admin/payments/${refundPaymentId}/refund`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: amountPence,
|
||||
reason: refundReason.trim()
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Refund processed');
|
||||
showRefundModal = false;
|
||||
fetchBooking();
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to process refund: ' + text);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error processing refund:', err);
|
||||
toast.error('Network error processing refund');
|
||||
} finally {
|
||||
refundLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal.Root bind:open>
|
||||
@@ -473,6 +529,57 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Payments -->
|
||||
{#if booking?.payments && booking.payments.length > 0}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Payments ({booking.payments.length})
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
{#each booking.payments as payment (payment.id)}
|
||||
<div class="flex items-center justify-between rounded-md border border-gray-300 bg-white p-3">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">
|
||||
{payment.payment_type === 'deposit' ? 'Deposit' : payment.payment_type === 'full' ? 'Full Payment' : payment.payment_type}
|
||||
{#if payment.payment_method}
|
||||
<span class="text-gray-500"> via {payment.payment_method}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600">
|
||||
<span
|
||||
class:text-green-600={payment.status === 'completed'}
|
||||
class:text-amber-600={payment.status === 'pending'}
|
||||
class:text-red-600={payment.status === 'failed' || payment.status === 'refunded'}
|
||||
>
|
||||
{payment.status}
|
||||
</span>
|
||||
<span class="mx-1">|</span>
|
||||
£{(payment.amount / 100).toFixed(2)}
|
||||
{#if payment.invoice_number}
|
||||
<span class="mx-1">|</span>
|
||||
<span class="text-gray-500">Inv: {payment.invoice_number}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-400">
|
||||
{new Date(payment.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
{#if payment.status === 'completed'}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
onclick={() => openRefundModal(payment.id, payment.amount)}
|
||||
>
|
||||
Refund
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Notes -->
|
||||
<div>
|
||||
<label for="edit-notes" class="mb-2 block text-sm font-medium">Notes</label>
|
||||
@@ -665,6 +772,59 @@
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
<!-- Refund Dialog -->
|
||||
<Modal.Root open={showRefundModal} onOpenChange={(v) => (showRefundModal = v)}>
|
||||
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto">
|
||||
<Modal.Header>
|
||||
<Modal.Title class="text-lg font-semibold">Process Refund</Modal.Title>
|
||||
<Modal.Description>
|
||||
Enter the refund amount and reason.
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
<div class="space-y-4 px-4 pb-4">
|
||||
<div>
|
||||
<label for="refund-amount" class="mb-1 block text-xs text-gray-600">
|
||||
Refund Amount
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span class="text-gray-500">£</span>
|
||||
</div>
|
||||
<Input
|
||||
id="refund-amount"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
bind:value={refundAmount}
|
||||
class="no-spin w-full pl-7"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="refund-reason" class="mb-1 block text-xs text-gray-600">
|
||||
Reason (required)
|
||||
</label>
|
||||
<Textarea
|
||||
id="refund-reason"
|
||||
bind:value={refundReason}
|
||||
placeholder="Enter reason for refund..."
|
||||
rows={3}
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onclick={() => (showRefundModal = false)}>Cancel</Button>
|
||||
<Button onclick={processRefund} disabled={refundLoading || !refundAmount || !refundReason.trim()}>
|
||||
{refundLoading ? 'Processing...' : 'Confirm Refund'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
<style>
|
||||
:global(input[type='number']) {
|
||||
-moz-appearance: textfield;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
// INTENTIONAL: We use the browser's local timezone (getLocalTimeZone) because Crussell is a UK-only
|
||||
// salon app. All customers are physically in the UK and book UK appointment slots. We do NOT
|
||||
// auto-adjust for international timezones — the slot time shown is the actual UK salon time.
|
||||
@@ -50,6 +51,185 @@
|
||||
let isSubmitting = $state(false);
|
||||
let idempotencyKey = $state<string>('');
|
||||
|
||||
// =============== Payment State ===============
|
||||
let userDepositsRequired = $state<number>(0);
|
||||
let hasActiveBooking = $state<boolean>(false);
|
||||
let activeBookingCheckDone = $state<boolean>(false);
|
||||
let paymentMethods = $state<
|
||||
Array<{ id: string; brand: string; last4: string; expiry_month: number; expiry_year: number }>
|
||||
>([]);
|
||||
let paymentMethodsLoading = $state(false);
|
||||
let selectedPaymentMethod = $state<string | null>(null);
|
||||
let showNewCardForm = $state(false);
|
||||
let isProcessingPayment = $state(false);
|
||||
|
||||
// New card form fields
|
||||
let newCardNumber = $state('');
|
||||
let newCardExpiry = $state('');
|
||||
let newCardCVC = $state('');
|
||||
let saveCardForFuture = $state(false);
|
||||
|
||||
// Payment flow state
|
||||
let depositPaid = $state(false);
|
||||
let showPaymentForm = $state(false);
|
||||
|
||||
// Confirmation state
|
||||
let confirmedBooking = $state<{
|
||||
id: string;
|
||||
status: string;
|
||||
start_time: string;
|
||||
notes: string;
|
||||
} | null>(null);
|
||||
|
||||
// =============== Payment Functions ===============
|
||||
async function fetchUserDepositsRequired() {
|
||||
if (!authStore.isAuthenticated) {
|
||||
userDepositsRequired = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/user', {
|
||||
credentials: 'include',
|
||||
headers: authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {}
|
||||
});
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
userDepositsRequired = user.deposits_required ?? 0;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch user deposits status:', err);
|
||||
userDepositsRequired = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchActiveBookingStatus() {
|
||||
if (!authStore.isAuthenticated) {
|
||||
hasActiveBooking = false;
|
||||
activeBookingCheckDone = true;
|
||||
return;
|
||||
}
|
||||
|
||||
activeBookingCheckDone = false;
|
||||
try {
|
||||
// Check for pending bookings
|
||||
const pendingResp = await fetch('/api/bookings?status=pending&perPage=1', {
|
||||
credentials: 'include',
|
||||
headers: authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {}
|
||||
});
|
||||
if (pendingResp.ok) {
|
||||
const data = await pendingResp.json();
|
||||
if (data.bookings && data.bookings.length > 0) {
|
||||
hasActiveBooking = true;
|
||||
activeBookingCheckDone = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for confirmed bookings
|
||||
const confirmedResp = await fetch('/api/bookings?status=confirmed&perPage=1', {
|
||||
credentials: 'include',
|
||||
headers: authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {}
|
||||
});
|
||||
if (confirmedResp.ok) {
|
||||
const data = await confirmedResp.json();
|
||||
hasActiveBooking = data.bookings && data.bookings.length > 0;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check active booking status:', err);
|
||||
hasActiveBooking = false;
|
||||
} finally {
|
||||
activeBookingCheckDone = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPaymentMethods() {
|
||||
if (!authStore.isAuthenticated) {
|
||||
paymentMethods = [];
|
||||
return;
|
||||
}
|
||||
|
||||
paymentMethodsLoading = true;
|
||||
try {
|
||||
const response = await fetch('/api/user/payment-methods', {
|
||||
credentials: 'include',
|
||||
headers: authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
paymentMethods = data.payment_methods ?? [];
|
||||
} else {
|
||||
paymentMethods = [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch payment methods:', err);
|
||||
paymentMethods = [];
|
||||
} finally {
|
||||
paymentMethodsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function calculateDepositRequired(): boolean {
|
||||
if (!selectedDate || !selectedTime) return false;
|
||||
|
||||
// Deposit required if user has deposits_required > 0 AND appointment is within 24 hours
|
||||
const [hours, minutes] = selectedTime.split(':').map(Number);
|
||||
const appointmentDate = selectedDate.toDate(getLocalTimeZone());
|
||||
appointmentDate.setHours(hours, minutes, 0, 0);
|
||||
|
||||
const now = new Date();
|
||||
const hoursUntilAppointment = (appointmentDate.getTime() - now.getTime()) / (1000 * 60 * 60);
|
||||
|
||||
return userDepositsRequired > 0 && hoursUntilAppointment <= 24;
|
||||
}
|
||||
|
||||
function calculateDepositAmount(): number {
|
||||
return Math.round(getTotalPrice() * 0.2 * 100) / 100;
|
||||
}
|
||||
|
||||
async function processPayment(amount: number) {
|
||||
isProcessingPayment = true;
|
||||
try {
|
||||
// TODO: Integrate Square SDK for actual payment processing
|
||||
// For now, simulate successful payment after a delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
toast.success('Payment successful!');
|
||||
depositPaid = true;
|
||||
showPaymentForm = false;
|
||||
nextStep();
|
||||
} catch (err) {
|
||||
toast.error('Payment failed. Please try again.');
|
||||
} finally {
|
||||
isProcessingPayment = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePayNow() {
|
||||
showPaymentForm = true;
|
||||
if (authStore.isAuthenticated) {
|
||||
fetchPaymentMethods();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSkipPayment() {
|
||||
depositPaid = false;
|
||||
nextStep();
|
||||
}
|
||||
|
||||
function formatCardExpiry(month: number, year: number): string {
|
||||
return `${String(month).padStart(2, '0')}/${year.toString().slice(-2)}`;
|
||||
}
|
||||
|
||||
// Fetch user deposit and active booking status when step 1 is reached
|
||||
$effect(() => {
|
||||
if (currentStep === 1 && authStore.isAuthenticated) {
|
||||
fetchUserDepositsRequired();
|
||||
fetchActiveBookingStatus();
|
||||
}
|
||||
});
|
||||
|
||||
// =============== Slot Reservation System ===============
|
||||
let reservationId = $state<string | null>(null);
|
||||
let reservationExpiresAt = $state<Date | null>(null);
|
||||
@@ -500,7 +680,10 @@
|
||||
function generateGroupedTimeSlots(
|
||||
duration: number,
|
||||
date: CalendarDate | undefined,
|
||||
lunchProtection: Map<string, { isBlocked: boolean; showWarning: boolean; warningMessage?: string }> = new Map()
|
||||
lunchProtection: Map<
|
||||
string,
|
||||
{ isBlocked: boolean; showWarning: boolean; warningMessage?: string }
|
||||
> = new Map()
|
||||
): Array<{
|
||||
type: 'available' | 'unavailable';
|
||||
startTime: string;
|
||||
@@ -550,13 +733,17 @@
|
||||
const minute = minutes % 60;
|
||||
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
|
||||
const isAvailable = availableSlots.includes(timeStr) && !lunchProtection.get(timeStr)?.isBlocked;
|
||||
const isAvailable =
|
||||
availableSlots.includes(timeStr) && !lunchProtection.get(timeStr)?.isBlocked;
|
||||
|
||||
if (isAvailable) {
|
||||
if (currentUnavailableStart !== null) {
|
||||
const groupEndTime = calculatePreviousTime(timeStr);
|
||||
const unavailableStartTime = currentUnavailableStart || lastAvailableEndTime;
|
||||
if (unavailableStartTime && timeToMinutes(unavailableStartTime) < timeToMinutes(groupEndTime)) {
|
||||
if (
|
||||
unavailableStartTime &&
|
||||
timeToMinutes(unavailableStartTime) < timeToMinutes(groupEndTime)
|
||||
) {
|
||||
groupedSlots.push({
|
||||
type: 'unavailable',
|
||||
startTime: unavailableStartTime,
|
||||
@@ -698,10 +885,10 @@
|
||||
const selStart = selHour * 60 + selMinute;
|
||||
const selEnd = selStart + duration;
|
||||
|
||||
const stillAvailable = dayAvailable.some(slot => {
|
||||
const stillAvailable = dayAvailable.some((slot) => {
|
||||
const [sH, sM] = slot.startTime.split(':').map(Number);
|
||||
const [eH, eM] = slot.endTime.split(':').map(Number);
|
||||
return selStart >= (sH * 60 + sM) && selEnd <= (eH * 60 + eM);
|
||||
return selStart >= sH * 60 + sM && selEnd <= eH * 60 + eM;
|
||||
});
|
||||
|
||||
if (!stillAvailable) {
|
||||
@@ -768,6 +955,22 @@
|
||||
if (!reserved) return;
|
||||
}
|
||||
|
||||
// Step 3 -> Step 4 (if deposit required) or Step 5 (submit booking)
|
||||
if (currentStep === 3) {
|
||||
if (calculateDepositRequired()) {
|
||||
currentStep = 4;
|
||||
} else {
|
||||
await submitAndProceed();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 4 -> Step 5 (submit booking)
|
||||
if (currentStep === 4) {
|
||||
await submitAndProceed();
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStep < 5) {
|
||||
currentStep++;
|
||||
setTimeout(() => {
|
||||
@@ -776,6 +979,152 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAndProceed() {
|
||||
isSubmitting = true;
|
||||
try {
|
||||
if (!idempotencyKey) {
|
||||
// Generate UUID v4 manually for environments where crypto.randomUUID() is unavailable
|
||||
const array = new Uint8Array(16);
|
||||
if (typeof window !== 'undefined' && window.crypto) {
|
||||
window.crypto.getRandomValues(array);
|
||||
} else {
|
||||
for (let i = 0; i < 16; i++) array[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
array[6] = (array[6] & 0x0f) | 0x40; // version 4
|
||||
array[8] = (array[8] & 0x3f) | 0x80; // variant 1
|
||||
idempotencyKey = [...array]
|
||||
.map((b, i) => {
|
||||
const hex = b.toString(16).padStart(2, '0');
|
||||
if (i === 4 || i === 6 || i === 8 || i === 10) return '-' + hex;
|
||||
return hex;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
if (!selectedDate || !selectedTime) {
|
||||
toast.error('Please select a date and time');
|
||||
isSubmitting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const [hours, minutes] = selectedTime.split(':').map(Number);
|
||||
const bookingDate = selectedDate.toDate(getLocalTimeZone());
|
||||
bookingDate.setHours(hours, minutes, 0, 0);
|
||||
const startTimeISO = bookingDate.toISOString();
|
||||
|
||||
const serviceIds = selectedServices.map((s) => s.id);
|
||||
|
||||
let guestUserId: string | null = null;
|
||||
if (!authStore.isAuthenticated) {
|
||||
const guestResponse = await fetch('/api/users/guest', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
firstName: customerInfo.firstName,
|
||||
lastName: customerInfo.lastName,
|
||||
email: customerInfo.email,
|
||||
phone: customerInfo.phone
|
||||
})
|
||||
});
|
||||
|
||||
if (!guestResponse.ok) {
|
||||
const errorText = await guestResponse.text();
|
||||
if (guestResponse.status === 409) {
|
||||
toast.error('Email already registered — please log in to book.');
|
||||
} else {
|
||||
toast.error('Failed to create guest account. Please try again.');
|
||||
}
|
||||
isSubmitting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const guestData = await guestResponse.json();
|
||||
guestUserId = guestData.id;
|
||||
}
|
||||
|
||||
const requestBody: Record<string, unknown> = {
|
||||
service_ids: serviceIds,
|
||||
start_time: startTimeISO,
|
||||
notes: customerInfo.specialRequests || null
|
||||
};
|
||||
|
||||
if (guestUserId) {
|
||||
requestBody.user_id = guestUserId;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': idempotencyKey
|
||||
};
|
||||
if (authStore.currentToken) {
|
||||
headers['Authorization'] = `Bearer ${authStore.currentToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/bookings', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const booking = await response.json();
|
||||
confirmedBooking = {
|
||||
id: booking.id,
|
||||
status: booking.status,
|
||||
start_time: booking.start_time,
|
||||
notes: booking.notes || ''
|
||||
};
|
||||
currentStep = 5;
|
||||
setTimeout(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}, 50);
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
let errorMessage = errorText.trim();
|
||||
if (!errorMessage) {
|
||||
errorMessage = 'Failed to submit booking. Please try again.';
|
||||
}
|
||||
// Backend may return plain text or JSON
|
||||
try {
|
||||
const errorData = JSON.parse(errorText);
|
||||
if (errorData.error) errorMessage = errorData.error;
|
||||
} catch {
|
||||
/* use raw text from backend */
|
||||
}
|
||||
|
||||
if (response.status === 409) {
|
||||
if (errorMessage.includes('active booking') || errorMessage.includes('already have')) {
|
||||
toast.error(
|
||||
'You already have an active booking. Please complete or cancel it before creating a new one.'
|
||||
);
|
||||
} else {
|
||||
toast.error('This time slot is no longer available. Please choose a different time.');
|
||||
}
|
||||
} else if (errorMessage.includes('patch test') || errorMessage.includes('Patch test')) {
|
||||
toast.error(errorMessage + ' Please complete a patch test first.');
|
||||
} else if (
|
||||
errorMessage.includes('48 hours') ||
|
||||
errorMessage.includes('48h') ||
|
||||
errorMessage.includes('advance')
|
||||
) {
|
||||
toast.error(errorMessage);
|
||||
} else if (errorMessage.includes('deposit') || errorMessage.includes('Deposit')) {
|
||||
toast.error(errorMessage);
|
||||
} else if (response.status === 400) {
|
||||
toast.error(errorMessage);
|
||||
} else {
|
||||
toast.error('Failed to submit booking: ' + errorMessage);
|
||||
}
|
||||
console.error('Booking submission failed:', response.status, errorText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Booking submission error:', error);
|
||||
toast.error('Network error. Please check your connection and try again.');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
if (currentStep > 1) {
|
||||
currentStep--;
|
||||
@@ -804,153 +1153,6 @@
|
||||
)) && !reservationExpired
|
||||
);
|
||||
const canProceedStep4 = $derived(true);
|
||||
|
||||
// =============== Submission ===============
|
||||
async function submitBooking() {
|
||||
isSubmitting = true;
|
||||
try {
|
||||
// Generate idempotency key if not already set (reused on retry)
|
||||
if (!idempotencyKey) {
|
||||
idempotencyKey = crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Build the start_time in ISO format
|
||||
if (!selectedDate || !selectedTime) {
|
||||
toast.error('Please select a date and time');
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert CalendarDate to JavaScript Date, then to ISO string
|
||||
const [hours, minutes] = selectedTime.split(':').map(Number);
|
||||
const bookingDate = selectedDate.toDate(getLocalTimeZone());
|
||||
bookingDate.setHours(hours, minutes, 0, 0);
|
||||
const startTimeISO = bookingDate.toISOString();
|
||||
|
||||
// Extract service IDs
|
||||
const serviceIds = selectedServices.map((s) => s.id);
|
||||
|
||||
// For guest users, create a guest account first
|
||||
let guestUserId: string | null = null;
|
||||
if (!authStore.isAuthenticated) {
|
||||
const guestResponse = await fetch('/api/users/guest', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
firstName: customerInfo.firstName,
|
||||
lastName: customerInfo.lastName,
|
||||
email: customerInfo.email,
|
||||
phone: customerInfo.phone
|
||||
})
|
||||
});
|
||||
|
||||
if (!guestResponse.ok) {
|
||||
const errorText = await guestResponse.text();
|
||||
if (guestResponse.status === 409) {
|
||||
toast.error('Email already registered — please log in to book.');
|
||||
} else {
|
||||
toast.error('Failed to create guest account. Please try again.');
|
||||
}
|
||||
isSubmitting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const guestData = await guestResponse.json();
|
||||
guestUserId = guestData.id;
|
||||
}
|
||||
|
||||
// Build request body
|
||||
const requestBody: Record<string, unknown> = {
|
||||
service_ids: serviceIds,
|
||||
start_time: startTimeISO,
|
||||
notes: customerInfo.specialRequests || null
|
||||
};
|
||||
|
||||
if (guestUserId) {
|
||||
requestBody.user_id = guestUserId;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey };
|
||||
if (authStore.currentToken) {
|
||||
headers['Authorization'] = `Bearer ${authStore.currentToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/bookings', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const booking = await response.json();
|
||||
|
||||
// Show success message with booking details
|
||||
const bookingDateStr = new Date(booking.start_time).toLocaleDateString('en-GB', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
});
|
||||
const bookingTimeStr = new Date(booking.start_time).toLocaleTimeString('en-GB', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
toast.success(`Booking confirmed for ${bookingDateStr} at ${bookingTimeStr}!`);
|
||||
|
||||
// Clear reservation state
|
||||
reservationId = null;
|
||||
reservationExpiresAt = null;
|
||||
|
||||
// Reset form and redirect
|
||||
selectedServices = [];
|
||||
selectedDate = undefined;
|
||||
selectedTime = null;
|
||||
currentStep = 1;
|
||||
|
||||
// Navigate to account page (logged-in) or home (guest)
|
||||
window.location.href = authStore.isAuthenticated ? '/account' : '/';
|
||||
} else {
|
||||
// Handle error response
|
||||
const errorText = await response.text();
|
||||
let errorMessage = 'Failed to submit booking. Please try again.';
|
||||
|
||||
// Try to parse error message from backend
|
||||
try {
|
||||
const errorData = JSON.parse(errorText);
|
||||
if (errorData.error) {
|
||||
errorMessage = errorData.error;
|
||||
} else if (typeof errorData === 'string') {
|
||||
errorMessage = errorData;
|
||||
}
|
||||
} catch {
|
||||
// Use default message if parsing fails
|
||||
}
|
||||
|
||||
// Handle specific error cases
|
||||
if (response.status === 409) {
|
||||
// Check if it's an active booking conflict or time slot conflict
|
||||
if (errorMessage.includes('active booking') || errorMessage.includes('already have')) {
|
||||
toast.error('You already have an active booking. Please complete or cancel it before creating a new one.');
|
||||
} else {
|
||||
toast.error('This time slot is no longer available. Please choose a different time.');
|
||||
}
|
||||
} else if (errorMessage.includes('patch test') || errorMessage.includes('Patch test')) {
|
||||
toast.error(errorMessage + ' Please complete a patch test first.');
|
||||
} else if (errorMessage.includes('48 hours') || errorMessage.includes('48h') || errorMessage.includes('advance')) {
|
||||
toast.error(errorMessage);
|
||||
} else if (response.status === 400) {
|
||||
toast.error(errorMessage);
|
||||
} else {
|
||||
toast.error('Failed to submit booking: ' + errorMessage);
|
||||
}
|
||||
console.error('Booking submission failed:', response.status, errorText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Booking submission error:', error);
|
||||
toast.error('Network error. Please check your connection and try again.');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl p-6">
|
||||
@@ -959,7 +1161,10 @@
|
||||
<p class="text-gray-600">Professional beauty treatments in a calm and friendly environment</p>
|
||||
</div>
|
||||
|
||||
<StepIndicator {currentStep} steps={['Service', 'Date & Time', 'Details', 'Payment & Review', 'Confirm']} />
|
||||
<StepIndicator
|
||||
{currentStep}
|
||||
steps={['Service', 'Date & Time', 'Details', 'Payment', 'Confirmation']}
|
||||
/>
|
||||
|
||||
<!-- Step 1: Service Selection -->
|
||||
{#if currentStep === 1}
|
||||
@@ -969,6 +1174,51 @@
|
||||
<Card.Description>Select one or more treatments for your appointment</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<!-- Warning: Active booking limit for deposit-owing users -->
|
||||
{#if authStore.isAuthenticated && userDepositsRequired > 0 && hasActiveBooking}
|
||||
<div
|
||||
class="rounded-lg border border-amber-200/60 bg-gradient-to-r from-amber-50 to-amber-100/50 p-4"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-amber-600"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="space-y-1 text-sm text-amber-900">
|
||||
<h4 class="font-semibold text-amber-800">Booking Limit While Deposits Are Owed</h4>
|
||||
{#if userDepositsRequired === 1}
|
||||
<p>
|
||||
You have <span class="font-medium">1 deposit remaining</span>. After this
|
||||
deposit is paid, you'll be able to book in advance again with no further
|
||||
deposits required.
|
||||
</p>
|
||||
{:else}
|
||||
<p>
|
||||
You currently owe <span class="font-medium"
|
||||
>{userDepositsRequired} deposits</span
|
||||
>. You can only have <span class="font-medium">1 upcoming booking</span> at a time
|
||||
while deposits are outstanding.
|
||||
</p>
|
||||
<p>
|
||||
Once your current booking is complete and paid for, you'll be able to book
|
||||
again.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ServiceSelector
|
||||
{services}
|
||||
selected={selectedServices}
|
||||
@@ -1197,8 +1447,8 @@
|
||||
appointment reminders via email and/or SMS.
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
<strong>Cancellation Policy:</strong> Free cancellation up to 24 hours before your
|
||||
appointment. Cancellations within 24 hours may incur a deposit penalty.
|
||||
<strong>Cancellation Policy:</strong> Free cancellation up to 24 hours before your appointment.
|
||||
Cancellations within 24 hours may incur a deposit penalty.
|
||||
</p>
|
||||
</div>
|
||||
</Card.Content>
|
||||
@@ -1215,12 +1465,15 @@
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Step 4: Payment & Review -->
|
||||
<!-- Step 4: Deposit Payment (only shown if deposit required) -->
|
||||
{#if currentStep === 4}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Payment & Review</Card.Title>
|
||||
<Card.Description>Review your booking details</Card.Description>
|
||||
<Card.Title>Pay Your Deposit</Card.Title>
|
||||
<Card.Description>
|
||||
A deposit of <span class="font-semibold">£{calculateDepositAmount()}</span> is required to secure
|
||||
your appointment.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<BookingSummary
|
||||
@@ -1239,11 +1492,147 @@
|
||||
showCustomer={true}
|
||||
/>
|
||||
|
||||
<!-- TODO: Integrate payment processor (Stripe/Square) here. Current flow: logged-in users go straight to confirmation, guest users show payment step before confirmation. -->
|
||||
<div class="rounded-lg bg-white p-6">
|
||||
<h2 class="mb-4 text-2xl font-semibold">Payment</h2>
|
||||
<p class="text-gray-600">Square payment integration will be added here.</p>
|
||||
{#if !showPaymentForm}
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-6">
|
||||
<h3 class="mb-2 text-lg font-semibold text-amber-800">Deposit Required</h3>
|
||||
<p class="mb-4 text-amber-700">
|
||||
Due to your booking being within 24 hours, a deposit is required.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button
|
||||
onclick={() => {
|
||||
showPaymentForm = true;
|
||||
}}
|
||||
class="bg-primary text-primary-foreground"
|
||||
>
|
||||
Pay Deposit Now
|
||||
</Button>
|
||||
<Button variant="outline" onclick={nextStep}>Pay at Appointment</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 class="mb-4 text-xl font-semibold">Pay Deposit</h3>
|
||||
|
||||
{#if authStore.isAuthenticated}
|
||||
{#if paymentMethodsLoading}
|
||||
<div class="py-4 text-center text-gray-500">Loading payment methods...</div>
|
||||
{:else if paymentMethods.length > 0}
|
||||
<div class="mb-6">
|
||||
<h4 class="mb-3 text-sm font-medium text-gray-700">Saved Cards</h4>
|
||||
<div class="space-y-3">
|
||||
{#each paymentMethods as method (method.id)}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border border-gray-200 p-3 {selectedPaymentMethod ===
|
||||
method.id
|
||||
? 'border-primary bg-primary/5'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-14 items-center justify-center rounded bg-gray-100 text-xs font-medium"
|
||||
>
|
||||
{method.brand}
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span class="font-mono">**** {method.last4}</span>
|
||||
<span class="ml-2 text-gray-500">
|
||||
{formatCardExpiry(method.expiry_month, method.expiry_year)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={selectedPaymentMethod === method.id ? 'default' : 'outline'}
|
||||
onclick={() => {
|
||||
selectedPaymentMethod = method.id;
|
||||
showNewCardForm = false;
|
||||
}}
|
||||
>
|
||||
{selectedPaymentMethod === method.id ? 'Selected' : 'Use this card'}
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !showNewCardForm}
|
||||
<Button
|
||||
variant="outline"
|
||||
class="mb-6"
|
||||
onclick={() => {
|
||||
showNewCardForm = true;
|
||||
selectedPaymentMethod = null;
|
||||
}}
|
||||
>
|
||||
+ Add new card
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if showNewCardForm || !authStore.isAuthenticated}
|
||||
<div class="mb-6 rounded-lg border border-gray-100 bg-gray-50 p-4">
|
||||
<h4 class="mb-4 text-sm font-medium text-gray-700">Card Details</h4>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="cardNumber">Card Number</Label>
|
||||
<Input
|
||||
id="cardNumber"
|
||||
bind:value={newCardNumber}
|
||||
placeholder="1234 5678 9012 3456"
|
||||
maxlength={19}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="cardExpiry">Expiry (MM/YY)</Label>
|
||||
<Input
|
||||
id="cardExpiry"
|
||||
bind:value={newCardExpiry}
|
||||
placeholder="MM/YY"
|
||||
maxlength={5}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="cardCVC">CVC</Label>
|
||||
<Input id="cardCVC" bind:value={newCardCVC} placeholder="123" maxlength={4} />
|
||||
</div>
|
||||
</div>
|
||||
{#if authStore.isAuthenticated}
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox id="saveCard" bind:checked={saveCardForFuture} />
|
||||
<Label for="saveCard" class="text-sm font-normal">
|
||||
Save card for next time
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between border-t pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={() => {
|
||||
showPaymentForm = false;
|
||||
selectedPaymentMethod = null;
|
||||
showNewCardForm = false;
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isProcessingPayment ||
|
||||
(!selectedPaymentMethod && !newCardNumber && !showNewCardForm)}
|
||||
onclick={() => processPayment(calculateDepositAmount())}
|
||||
class="bg-primary text-primary-foreground"
|
||||
>
|
||||
{isProcessingPayment ? 'Processing...' : `Pay £${calculateDepositAmount()}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-between">
|
||||
<Button variant="outline" onclick={prevStep}>Back</Button>
|
||||
@@ -1252,36 +1641,158 @@
|
||||
onclick={nextStep}
|
||||
class="bg-primary text-primary-foreground"
|
||||
>
|
||||
Next: Confirm
|
||||
{isSubmitting ? 'Processing...' : 'Skip Payment'}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Step 5: Confirm -->
|
||||
<!-- Step 5: Confirmation -->
|
||||
{#if currentStep === 5}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Confirm Your Booking</Card.Title>
|
||||
<Card.Description>Ready to confirm your appointment</Card.Description>
|
||||
{#if confirmedBooking}
|
||||
{@const isRequested = confirmedBooking.notes && confirmedBooking.notes.length > 0}
|
||||
{@const bookingDate = new SvelteDate(confirmedBooking.start_time)}
|
||||
{@const dateStr = bookingDate.toLocaleDateString('en-GB', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}
|
||||
{@const timeStr = bookingDate.toLocaleTimeString('en-GB', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})}
|
||||
|
||||
<Card.Root class="border-emerald-200">
|
||||
<Card.Header class="text-center">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full {isRequested
|
||||
? 'bg-amber-100'
|
||||
: 'bg-emerald-100'}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 {isRequested ? 'text-amber-600' : 'text-emerald-600'}"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<Card.Title class="text-2xl font-bold"
|
||||
>{isRequested ? 'Booking Requested' : 'Booking Confirmed'}</Card.Title
|
||||
>
|
||||
<Card.Description class="mt-2 text-base">
|
||||
{isRequested
|
||||
? "Your booking has been submitted and is awaiting approval. We'll notify you once it's confirmed."
|
||||
: 'Your appointment has been booked successfully.'}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<div class="rounded-lg bg-gray-50 p-6 text-center">
|
||||
<p class="text-lg">
|
||||
Ready to confirm: {selectedServices.map((s) => s.name).join(', ')} on {selectedDate?.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })} at {selectedTime}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-500">Confirmation Number</span>
|
||||
<span class="font-mono text-lg font-bold text-gray-900">{confirmedBooking.id}</span>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Date</div>
|
||||
<div class="font-medium">{dateStr}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Time</div>
|
||||
<div class="font-medium">{timeStr}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Duration</div>
|
||||
<div class="font-medium">{getTotalDuration()} minutes (estimated)</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Status</div>
|
||||
<div class="font-medium">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {isRequested
|
||||
? 'bg-amber-100 text-amber-800'
|
||||
: 'bg-emerald-100 text-emerald-800'}"
|
||||
>
|
||||
{isRequested ? 'Pending Approval' : 'Confirmed'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-6">
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-600 uppercase">Services</h4>
|
||||
<div class="space-y-2">
|
||||
{#each selectedServices as service (service.id)}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span>{service.name}</span>
|
||||
<span class="text-gray-600"
|
||||
>{service.duration_minutes} min • £{service.price}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="border-t pt-2">
|
||||
<div class="flex justify-between font-semibold">
|
||||
<span>Total (estimated)</span>
|
||||
<span>£{getTotalPrice()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isRequested}
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<p class="text-sm text-amber-800">
|
||||
<strong>Please note:</strong> Because you included special requests, the cost and duration
|
||||
shown are estimates. We may adjust these after reviewing your requirements. You'll receive
|
||||
a notification once your booking is approved.
|
||||
</p>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-between">
|
||||
<Button variant="outline" onclick={prevStep}>Back</Button>
|
||||
{/if}
|
||||
|
||||
{#if !calculateDepositRequired()}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-6 text-center">
|
||||
<h3 class="mb-2 text-lg font-semibold">Pay on the Day</h3>
|
||||
<p class="mb-4 text-gray-600">
|
||||
You can pay when you arrive, or pay ahead of time to speed things up.
|
||||
</p>
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
onclick={submitBooking}
|
||||
class="bg-primary text-primary-foreground"
|
||||
onclick={() =>
|
||||
(window.location.href = `/booking-confirmed/${confirmedBooking!.id}`)}
|
||||
class="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
>
|
||||
{isSubmitting ? 'Processing...' : 'Confirm Booking'}
|
||||
Pay Early
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-center">
|
||||
<Button
|
||||
onclick={() => (window.location.href = authStore.isAuthenticated ? '/account' : '/')}
|
||||
class="w-full"
|
||||
>
|
||||
{authStore.isAuthenticated ? 'View My Bookings' : 'Return Home'}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
<Card.Root>
|
||||
<Card.Content class="flex items-center justify-center p-12">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
|
||||
></div>
|
||||
<p class="text-gray-600">Confirming your booking...</p>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import type { Booking } from '$lib/types/booking';
|
||||
|
||||
interface Props {
|
||||
booking: Booking;
|
||||
onClose: () => void;
|
||||
onComplete: (payment: PaymentResult) => void;
|
||||
}
|
||||
|
||||
let { booking, onClose, onComplete }: Props = $props();
|
||||
|
||||
type PaymentStatus = 'idle' | 'processing' | 'polling' | 'success' | 'error';
|
||||
|
||||
type PaymentResult = {
|
||||
checkout_id: string;
|
||||
status: string;
|
||||
card_brand?: string;
|
||||
last4?: string;
|
||||
amount: number;
|
||||
};
|
||||
|
||||
let status = $state<PaymentStatus>('idle');
|
||||
let checkoutId = $state<string | null>(null);
|
||||
let paymentResult = $state<PaymentResult | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
let amount = $derived(booking.total_amount);
|
||||
let overrideAmount = $state<string>('');
|
||||
let tipEnabled = $state(false);
|
||||
|
||||
let pollingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Calculate total with tip
|
||||
let totalWithTip = $derived(tipEnabled ? amount * 1.1 : amount);
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
return new Intl.NumberFormat('en-GB', {
|
||||
style: 'currency',
|
||||
currency: 'GBP'
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
async function handleConfirmPayment() {
|
||||
const finalAmount = overrideAmount ? parseFloat(overrideAmount) : totalWithTip;
|
||||
|
||||
if (isNaN(finalAmount) || finalAmount <= 0) {
|
||||
toast.error('Please enter a valid amount');
|
||||
return;
|
||||
}
|
||||
|
||||
status = 'processing';
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/bookings/${booking.id}/payment`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
amount: Math.round(finalAmount * 100),
|
||||
payment_type: 'full',
|
||||
tip_enabled: tipEnabled
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.text();
|
||||
throw new Error(errData || 'Failed to initiate payment');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
checkoutId = data.checkout_id;
|
||||
status = 'polling';
|
||||
startPolling();
|
||||
} catch (err) {
|
||||
status = 'error';
|
||||
error = err instanceof Error ? err.message : 'Failed to initiate payment';
|
||||
toast.error(error ?? 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (!checkoutId) return;
|
||||
|
||||
pollingInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/admin/payments/${checkoutId}/status?booking_id=${booking.id}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include'
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to check payment status');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'COMPLETED') {
|
||||
stopPolling();
|
||||
status = 'success';
|
||||
paymentResult = {
|
||||
checkout_id: checkoutId!,
|
||||
status: data.status,
|
||||
card_brand: data.card_brand,
|
||||
last4: data.last4,
|
||||
amount: data.amount
|
||||
};
|
||||
toast.success('Payment successful');
|
||||
onComplete(paymentResult);
|
||||
} else if (data.status === 'FAILED') {
|
||||
stopPolling();
|
||||
status = 'error';
|
||||
const errorMsg = data.error_message || 'Payment failed';
|
||||
error = errorMsg;
|
||||
toast.error(errorMsg as string);
|
||||
}
|
||||
// PENDING - continue polling
|
||||
} catch (err) {
|
||||
stopPolling();
|
||||
status = 'error';
|
||||
const errorMsg = 'Failed to check payment status';
|
||||
error = errorMsg;
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollingInterval) {
|
||||
clearInterval(pollingInterval);
|
||||
pollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRetry() {
|
||||
status = 'idle';
|
||||
checkoutId = null;
|
||||
error = null;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
stopPolling();
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
$effect(() => {
|
||||
return () => {
|
||||
stopPolling();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if status === 'idle' || status === 'processing' || status === 'error'}
|
||||
<div class="space-y-4">
|
||||
<!-- Service Breakdown -->
|
||||
<div class="rounded-md border border-gray-200 bg-gray-50 p-4">
|
||||
<div class="mb-3 text-sm font-semibold text-gray-700">Services</div>
|
||||
<div class="space-y-2">
|
||||
{#each booking.services ?? [] as service, index (index)}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">{service.service_name || 'Unknown Service'}</span>
|
||||
<span class="font-medium">
|
||||
{service.price ? formatCurrency(service.price) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Amount -->
|
||||
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
|
||||
<span class="text-base font-semibold text-gray-700">Total</span>
|
||||
<span class="text-xl font-bold text-gray-900">{formatCurrency(amount)}</span>
|
||||
</div>
|
||||
|
||||
<!-- Price Override -->
|
||||
<div class="space-y-2">
|
||||
<label for="override-amount" class="text-sm font-medium text-gray-700">
|
||||
Override Amount (optional)
|
||||
</label>
|
||||
<Input
|
||||
id="override-amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="Leave empty to use total"
|
||||
bind:value={overrideAmount}
|
||||
disabled={status === 'processing'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tip Toggle -->
|
||||
<div class="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id="tip-enabled"
|
||||
bind:checked={tipEnabled}
|
||||
disabled={status === 'processing'}
|
||||
/>
|
||||
<label for="tip-enabled" class="text-sm text-gray-700">
|
||||
Add 10% tip ({formatCurrency(amount * 0.1)})
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if tipEnabled}
|
||||
<div class="flex justify-between rounded-md border border-green-200 bg-green-50 p-3">
|
||||
<span class="text-sm font-medium text-green-800">Total with Tip</span>
|
||||
<span class="text-lg font-bold text-green-800">
|
||||
{formatCurrency(totalWithTip)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if status === 'error' && error}
|
||||
<div class="rounded-md border border-red-200 bg-red-50 p-3">
|
||||
<p class="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
<Button variant="outline" onclick={handleRetry} class="w-full">
|
||||
Try Again
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<Button variant="outline" onclick={handleClose} class="flex-1" disabled={status === 'processing'}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onclick={handleConfirmPayment}
|
||||
class="flex-1 bg-green-600 hover:bg-green-700"
|
||||
loading={status === 'processing'}
|
||||
disabled={status === 'processing'}
|
||||
>
|
||||
Confirm Payment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if status === 'polling'}
|
||||
<!-- Polling State -->
|
||||
<div class="flex flex-col items-center justify-center py-8">
|
||||
<div class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"></div>
|
||||
<p class="text-lg font-medium text-gray-700">Waiting for customer to tap card...</p>
|
||||
<p class="mt-2 text-sm text-gray-500">This may take a few moments</p>
|
||||
<Button variant="outline" onclick={handleClose} class="mt-6">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{:else if status === 'success' && paymentResult}
|
||||
<!-- Success State -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col items-center justify-center py-4">
|
||||
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 text-green-600"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900">Payment Successful</h3>
|
||||
</div>
|
||||
|
||||
<!-- Receipt -->
|
||||
<div class="rounded-md border border-gray-200 bg-gray-50 p-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">Amount</span>
|
||||
<span class="font-semibold text-gray-900">
|
||||
{formatCurrency(paymentResult.amount)}
|
||||
</span>
|
||||
</div>
|
||||
{#if paymentResult.card_brand}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">Card</span>
|
||||
<span class="font-medium text-gray-900">
|
||||
{paymentResult.card_brand} ****{paymentResult.last4}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">Status</span>
|
||||
<span class="font-medium text-green-600">Completed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onclick={handleClose} class="w-full">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -6,6 +6,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import PaymentModal from '$lib/components/payments/PaymentModal.svelte';
|
||||
|
||||
interface Props {
|
||||
openBookingModal: (bookingId: string) => void;
|
||||
@@ -44,6 +45,7 @@
|
||||
let loading = $state(true);
|
||||
let timeRemaining = $state(0); // minutes remaining in current appointment
|
||||
let isInProgress = $state(false);
|
||||
let showPaymentModal = $state(false);
|
||||
|
||||
// Calculate time remaining and free time
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -163,7 +165,12 @@
|
||||
}
|
||||
|
||||
function handleTakePayment() {
|
||||
toast.info('Take payment - Coming soon');
|
||||
showPaymentModal = true;
|
||||
}
|
||||
|
||||
function handlePaymentComplete(payment: unknown) {
|
||||
toast.success('Payment completed');
|
||||
showPaymentModal = false;
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
@@ -425,3 +432,7 @@
|
||||
</Card.Content>
|
||||
{/if}
|
||||
</Card.Root>
|
||||
|
||||
{#if showPaymentModal}
|
||||
<PaymentModal booking={activeAppointment} onClose={() => showPaymentModal = false} onComplete={handlePaymentComplete} />
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
export interface UserSavedCard {
|
||||
id: string;
|
||||
square_card_id: string;
|
||||
brand: string;
|
||||
last_4: string;
|
||||
exp_month: number;
|
||||
exp_year: number;
|
||||
fingerprint: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export interface Payment {
|
||||
id: string;
|
||||
booking_id: string;
|
||||
payment_type: 'deposit' | 'full' | 'tip' | 'balance' | 'partial';
|
||||
payment_method: 'online_square' | 'in_person_card' | 'cash' | 'giftcard' | 'discount';
|
||||
vendor_code: string | null;
|
||||
invoice_number: number | null;
|
||||
status: 'pending' | 'completed' | 'failed' | 'refunded';
|
||||
amount: number;
|
||||
is_vat_applicable: boolean;
|
||||
vat_rate: number | null;
|
||||
vat_amount: number | null;
|
||||
net_amount: number | null;
|
||||
user_saved_card_id: string | null;
|
||||
square_payment_id: string | null;
|
||||
idempotency_key: string | null;
|
||||
fees: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
export interface Refund {
|
||||
id: string;
|
||||
payment_id: string;
|
||||
booking_id: string;
|
||||
amount: number;
|
||||
square_refund_id: string | null;
|
||||
status: 'pending' | 'completed' | 'failed' | 'refunded';
|
||||
reason: string;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PaymentSummary {
|
||||
total_amount: number;
|
||||
paid_amount: number;
|
||||
refunded_amount: number;
|
||||
remaining_amount: number;
|
||||
payments: Payment[];
|
||||
refunds: Refund[];
|
||||
}
|
||||
|
||||
export interface CheckoutResponse {
|
||||
checkout_id: string;
|
||||
status: 'PENDING' | 'COMPLETED' | 'FAILED';
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
|
||||
// Types
|
||||
type Service = {
|
||||
service_name: string;
|
||||
price: number;
|
||||
duration_minutes: number;
|
||||
};
|
||||
|
||||
type Booking = {
|
||||
id: string;
|
||||
start_time: string;
|
||||
status: string;
|
||||
services: Service[];
|
||||
deposit_required: boolean;
|
||||
deposit_amount?: number;
|
||||
deposit_deadline?: string;
|
||||
duration_minutes: number;
|
||||
};
|
||||
|
||||
type PaymentSummary = {
|
||||
total_amount: number;
|
||||
paid_amount: number;
|
||||
refunded_amount: number;
|
||||
remaining_amount: number;
|
||||
payments: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
payment_type: string;
|
||||
}>;
|
||||
refunds: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
// State
|
||||
let booking = $state<Booking | null>(null);
|
||||
let paymentSummary = $state<PaymentSummary | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Get booking ID from URL
|
||||
const bookingId = $derived($page.params.id);
|
||||
|
||||
// Derived values
|
||||
const totalDuration = $derived(
|
||||
booking?.services?.reduce((sum, s) => sum + s.duration_minutes, 0) ?? 0
|
||||
);
|
||||
|
||||
const totalPrice = $derived(
|
||||
booking?.services?.reduce((sum, s) => sum + s.price, 0) ?? 0
|
||||
);
|
||||
|
||||
const hasPaidDeposit = $derived(() => {
|
||||
if (!paymentSummary?.payments) return false;
|
||||
return paymentSummary.payments.some(
|
||||
(p) => p.status === 'completed' && (p.payment_type === 'deposit' || p.payment_type === 'full')
|
||||
);
|
||||
});
|
||||
|
||||
const hasPaidAnything = $derived(() => {
|
||||
if (!paymentSummary?.payments) return false;
|
||||
return paymentSummary.payments.some((p) => p.status === 'completed');
|
||||
});
|
||||
|
||||
// Format functions
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new SvelteDate(dateStr);
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const date = new SvelteDate(dateStr);
|
||||
return date.toLocaleTimeString('en-GB', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatDuration(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${remainingMinutes} minutes`;
|
||||
} else if (remainingMinutes === 0) {
|
||||
return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
||||
} else {
|
||||
return `${hours} ${hours === 1 ? 'hour' : 'hours'} ${remainingMinutes} minutes`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatPrice(pence: number): string {
|
||||
return `£${(pence / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatDeadline(dateStr: string): string {
|
||||
const date = new SvelteDate(dateStr);
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch data
|
||||
async function fetchBookingData() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
// Fetch booking details
|
||||
const bookingResponse = await fetch(`/api/bookings/${bookingId}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!bookingResponse.ok) {
|
||||
if (bookingResponse.status === 404) {
|
||||
throw new Error('Booking not found');
|
||||
}
|
||||
throw new Error('Failed to load booking');
|
||||
}
|
||||
|
||||
booking = await bookingResponse.json();
|
||||
|
||||
// Fetch payment summary
|
||||
const paymentResponse = await fetch(`/api/bookings/${bookingId}/payment-summary`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (paymentResponse.ok) {
|
||||
paymentSummary = await paymentResponse.json();
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'An error occurred';
|
||||
toast.error(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
$effect(() => {
|
||||
if (bookingId) {
|
||||
fetchBookingData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Booking Confirmed - Crussell</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl p-6">
|
||||
{#if loading}
|
||||
<div class="space-y-6">
|
||||
<div class="text-center">
|
||||
<Skeleton class="mx-auto h-12 w-64" />
|
||||
<Skeleton class="mx-auto mt-2 h-6 w-48" />
|
||||
</div>
|
||||
<Card.Root>
|
||||
<Card.Content class="space-y-4 pt-6">
|
||||
<Skeleton class="h-8 w-full" />
|
||||
<Skeleton class="h-20 w-full" />
|
||||
<Skeleton class="h-16 w-full" />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
{:else if error}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-red-600">Error</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<p class="text-gray-600">{error}</p>
|
||||
<Button class="mt-4" onclick={() => (window.location.href = '/')}>
|
||||
Return Home
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else if booking}
|
||||
<div class="mb-8 text-center">
|
||||
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-green-100">
|
||||
<svg
|
||||
class="h-10 w-10 text-green-600"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M20 6L9 17l-5-5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Booking Confirmed!</h1>
|
||||
<p class="mt-2 text-gray-600">Your appointment has been successfully booked</p>
|
||||
</div>
|
||||
|
||||
<Card.Root class="mb-6">
|
||||
<Card.Header>
|
||||
<Card.Title>Appointment Details</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="flex items-center justify-between border-b pb-4">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-500">Date</div>
|
||||
<div class="text-lg font-semibold">{formatDate(booking.start_time)}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm font-medium text-gray-500">Time</div>
|
||||
<div class="text-lg font-semibold">{formatTime(booking.start_time)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between border-b pb-4">
|
||||
<div class="text-sm font-medium text-gray-500">Estimated Duration</div>
|
||||
<div class="font-semibold">{formatDuration(totalDuration)}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-3 text-sm font-medium text-gray-500">Services</div>
|
||||
<div class="space-y-2">
|
||||
{#each booking.services as service}
|
||||
<div class="flex justify-between rounded bg-gray-50 p-3">
|
||||
<div>
|
||||
<div class="font-medium">{service.service_name}</div>
|
||||
<div class="text-sm text-gray-500">{service.duration_minutes} mins</div>
|
||||
</div>
|
||||
<div class="font-semibold">{formatPrice(service.price)}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between border-t pt-4">
|
||||
<div class="text-lg font-semibold">Total</div>
|
||||
<div class="text-lg font-bold">{formatPrice(totalPrice)}</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Payment</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if booking.deposit_required}
|
||||
{#if hasPaidDeposit()}
|
||||
<div class="flex items-center gap-3 rounded-lg border border-green-200 bg-green-50 p-4">
|
||||
<svg
|
||||
class="h-6 w-6 text-green-600"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M20 6L9 17l-5-5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<div>
|
||||
<div class="font-semibold text-green-800">Deposit Paid</div>
|
||||
<div class="text-sm text-green-700">
|
||||
Your deposit of {formatPrice(booking.deposit_amount ?? 0)} has been paid
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if paymentSummary && paymentSummary.remaining_amount > 0}
|
||||
<div class="mt-4 rounded-lg bg-gray-50 p-4">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Remaining Balance</span>
|
||||
<span class="font-semibold">{formatPrice(paymentSummary.remaining_amount)}</span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
You can pay the remaining balance on the day of your appointment
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-semibold text-amber-800">Deposit Required</div>
|
||||
<div class="text-sm text-amber-700">
|
||||
To secure your booking, please pay a deposit of {formatPrice(booking.deposit_amount ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-amber-800">
|
||||
{formatPrice(booking.deposit_amount ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
{#if booking.deposit_deadline}
|
||||
<div class="mt-2 text-sm text-amber-600">
|
||||
Please pay before {formatDeadline(booking.deposit_deadline)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Button class="w-full" onclick={() => toast.info('Payment integration coming soon')}>
|
||||
Pay Deposit Now
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-lg bg-gray-50 p-4">
|
||||
<p class="text-gray-600">
|
||||
You can pay on the day, but if you'd prefer you can pay ahead here
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if hasPaidAnything()}
|
||||
<div class="flex items-center gap-3 rounded-lg border border-green-200 bg-green-50 p-4">
|
||||
<svg
|
||||
class="h-6 w-6 text-green-600"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M20 6L9 17l-5-5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<div>
|
||||
<div class="font-semibold text-green-800">Paid</div>
|
||||
<div class="text-sm text-green-700">
|
||||
{formatPrice(paymentSummary?.paid_amount ?? 0)} paid
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Button class="w-full" onclick={() => toast.info('Payment integration coming soon')}>
|
||||
Pay Now
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<div class="mt-6 flex flex-col gap-4 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" onclick={() => (window.location.href = '/account')}>
|
||||
View My Bookings
|
||||
</Button>
|
||||
<Button variant="ghost" onclick={() => (window.location.href = '/')}>
|
||||
Return Home
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,387 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
|
||||
// Types
|
||||
type Service = {
|
||||
service_name: string;
|
||||
price: number;
|
||||
duration_minutes: number;
|
||||
};
|
||||
|
||||
type Booking = {
|
||||
id: string;
|
||||
start_time: string;
|
||||
status: string;
|
||||
customer_first_name: string;
|
||||
services: Service[];
|
||||
};
|
||||
|
||||
// State
|
||||
let booking = $state<Booking | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let paymentState = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
|
||||
|
||||
// Tip selection state
|
||||
let selectedTip = $state<number | null>(null);
|
||||
let customTip = $state('');
|
||||
let tipAmount = $derived(
|
||||
selectedTip !== null
|
||||
? selectedTip
|
||||
: customTip
|
||||
? parseFloat(customTip) || 0
|
||||
: 0
|
||||
);
|
||||
|
||||
// Card form state (placeholder for Square SDK)
|
||||
let cardNumber = $state('');
|
||||
let cardExpiry = $state('');
|
||||
let cardCvc = $state('');
|
||||
|
||||
// Get booking ID from URL
|
||||
const bookingId = $derived($page.params.id);
|
||||
|
||||
// Format functions
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new SvelteDate(dateStr);
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const date = new SvelteDate(dateStr);
|
||||
return date.toLocaleTimeString('en-GB', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatPrice(pence: number): string {
|
||||
return `£${(pence / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
// Fetch booking data
|
||||
async function fetchBookingData() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/bookings/${bookingId}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('Booking not found');
|
||||
}
|
||||
throw new Error('Failed to load booking');
|
||||
}
|
||||
|
||||
booking = await response.json();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'An error occurred';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tip selection
|
||||
function selectTip(amount: number) {
|
||||
selectedTip = amount;
|
||||
customTip = '';
|
||||
}
|
||||
|
||||
function handleCustomTipInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
customTip = input.value;
|
||||
selectedTip = null;
|
||||
}
|
||||
|
||||
// Submit tip payment
|
||||
async function submitTip() {
|
||||
if (tipAmount <= 0) {
|
||||
toast.error('Please select a tip amount');
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic validation for placeholder card form
|
||||
if (!cardNumber || !cardExpiry || !cardCvc) {
|
||||
toast.error('Please enter your card details');
|
||||
return;
|
||||
}
|
||||
|
||||
paymentState = 'processing';
|
||||
|
||||
try {
|
||||
const amountInPence = Math.round(tipAmount * 100);
|
||||
|
||||
const response = await fetch(`/api/bookings/${bookingId}/tip`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
amount: amountInPence,
|
||||
card_token: 'placeholder'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Payment failed');
|
||||
}
|
||||
|
||||
paymentState = 'success';
|
||||
toast.success('Thank you for your tip!');
|
||||
} catch (err) {
|
||||
paymentState = 'error';
|
||||
const errorMessage = err instanceof Error ? err.message : 'Payment failed';
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset and retry
|
||||
function retryPayment() {
|
||||
paymentState = 'idle';
|
||||
}
|
||||
|
||||
// Initialize
|
||||
$effect(() => {
|
||||
if (bookingId) {
|
||||
fetchBookingData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Leave a Tip - Crussell</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-md p-6">
|
||||
{#if loading}
|
||||
<div class="space-y-6">
|
||||
<div class="text-center">
|
||||
<Skeleton class="mx-auto h-10 w-40" />
|
||||
</div>
|
||||
<Card.Root>
|
||||
<Card.Content class="space-y-4 pt-6">
|
||||
<Skeleton class="h-16 w-full" />
|
||||
<Skeleton class="h-24 w-full" />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
{:else if error}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-red-600">Error</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<p class="text-gray-600">{error}</p>
|
||||
<Button class="mt-4" onclick={() => (window.location.href = '/')}>
|
||||
Return Home
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else if booking}
|
||||
<div class="mb-6 text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Leave a Tip</h1>
|
||||
<p class="mt-1 text-gray-600">Show your appreciation for great service</p>
|
||||
</div>
|
||||
|
||||
{#if paymentState === 'success'}
|
||||
<Card.Root>
|
||||
<Card.Content class="py-8 text-center">
|
||||
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
|
||||
<svg
|
||||
class="h-8 w-8 text-green-600"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M20 6L9 17l-5-5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900">Thank you for your tip!</h2>
|
||||
<p class="mt-2 text-gray-600">Your generosity is greatly appreciated.</p>
|
||||
<Button class="mt-6" onclick={() => (window.location.href = '/')}>
|
||||
Return Home
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
<Card.Root class="mb-6">
|
||||
<Card.Header>
|
||||
<Card.Title>Your Appointment</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-500">Name</span>
|
||||
<span class="font-medium">{booking.customer_first_name}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-500">Date</span>
|
||||
<span class="font-medium">{formatDate(booking.start_time)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-500">Time</span>
|
||||
<span class="font-medium">{formatTime(booking.start_time)}</span>
|
||||
</div>
|
||||
<div class="border-t pt-3">
|
||||
<div class="text-sm text-gray-500">Services</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
{#each booking.services as service}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-700">{service.service_name}</span>
|
||||
<span class="text-gray-500">{formatPrice(service.price)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root class="mb-6">
|
||||
<Card.Header>
|
||||
<Card.Title>Choose Tip Amount</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
class="rounded-lg border-2 py-3 text-center font-semibold transition-colors {selectedTip ===
|
||||
2
|
||||
? 'border-blue-600 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 hover:border-gray-300'}"
|
||||
onclick={() => selectTip(2)}
|
||||
>
|
||||
£2
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg border-2 py-3 text-center font-semibold transition-colors {selectedTip ===
|
||||
5
|
||||
? 'border-blue-600 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 hover:border-gray-300'}"
|
||||
onclick={() => selectTip(5)}
|
||||
>
|
||||
£5
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg border-2 py-3 text-center font-semibold transition-colors {selectedTip ===
|
||||
10
|
||||
? 'border-blue-600 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 hover:border-gray-300'}"
|
||||
onclick={() => selectTip(10)}
|
||||
>
|
||||
£10
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="custom-tip" class="text-sm font-medium text-gray-700">Or enter custom amount</label>
|
||||
<div class="mt-1 relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">£</span>
|
||||
<Input
|
||||
id="custom-tip"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
class="pl-7"
|
||||
value={customTip}
|
||||
oninput={handleCustomTipInput}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if tipAmount > 0}
|
||||
<div class="rounded-lg bg-blue-50 p-4 text-center">
|
||||
<span class="text-lg font-semibold text-blue-700">Tip: £{tipAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root class="mb-6">
|
||||
<Card.Header>
|
||||
<Card.Title>Card Details</Card.Title>
|
||||
<Card.Description>Secure payment powered by Square</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div>
|
||||
<label for="card-number" class="text-sm font-medium text-gray-700">Card Number</label>
|
||||
<Input
|
||||
id="card-number"
|
||||
type="text"
|
||||
placeholder="1234 5678 9012 3456"
|
||||
maxlength="19"
|
||||
bind:value={cardNumber}
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="card-expiry" class="text-sm font-medium text-gray-700">Expiry</label>
|
||||
<Input
|
||||
id="card-expiry"
|
||||
type="text"
|
||||
placeholder="MM/YY"
|
||||
maxlength="5"
|
||||
bind:value={cardExpiry}
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="card-cvc" class="text-sm font-medium text-gray-700">CVC</label>
|
||||
<Input
|
||||
id="card-cvc"
|
||||
type="text"
|
||||
placeholder="123"
|
||||
maxlength="4"
|
||||
bind:value={cardCvc}
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
{#if paymentState === 'error'}
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<p class="text-red-700">Payment failed. Please try again.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="mt-3 w-full"
|
||||
onclick={retryPayment}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
class="w-full"
|
||||
size="lg"
|
||||
disabled={tipAmount <= 0 || paymentState === 'processing'}
|
||||
loading={paymentState === 'processing'}
|
||||
onclick={submitTip}
|
||||
>
|
||||
{paymentState === 'processing' ? 'Processing...' : `Pay Tip £${tipAmount.toFixed(2)}`}
|
||||
</Button>
|
||||
|
||||
<p class="mt-4 text-center text-xs text-gray-500">
|
||||
This is a placeholder form. Square SDK integration coming soon.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -55,6 +55,21 @@ CREATE OR REPLACE FUNCTION generate_user_id() RETURNS CHAR(12) AS $$ SELECT gene
|
||||
CREATE OR REPLACE FUNCTION generate_service_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('services'); $$ LANGUAGE sql;
|
||||
CREATE OR REPLACE FUNCTION generate_booking_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('bookings'); $$ LANGUAGE sql;
|
||||
CREATE OR REPLACE FUNCTION generate_payment_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('payments'); $$ LANGUAGE sql;
|
||||
CREATE OR REPLACE FUNCTION generate_user_saved_card_id() RETURNS CHAR(12) AS $$
|
||||
SELECT generate_short_id('user_saved_cards');
|
||||
$$ LANGUAGE sql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION generate_refund_id() RETURNS CHAR(12) AS $$
|
||||
SELECT generate_short_id('refunds');
|
||||
$$ LANGUAGE sql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION generate_affiliate_payout_id() RETURNS CHAR(12) AS $$
|
||||
SELECT generate_short_id('affiliate_payouts');
|
||||
$$ LANGUAGE sql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION generate_square_deposit_id() RETURNS CHAR(12) AS $$
|
||||
SELECT generate_short_id('square_deposits');
|
||||
$$ LANGUAGE sql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION generate_verification_code() RETURNS CHAR(12) AS $$ SELECT substr(encode(gen_random_bytes(6), 'hex'), 1, 12); $$ LANGUAGE sql;
|
||||
|
||||
@@ -384,6 +399,11 @@ CREATE TABLE payments (
|
||||
vat_rate NUMERIC(5,2),
|
||||
vat_amount NUMERIC(10,2),
|
||||
net_amount NUMERIC(10,2),
|
||||
user_saved_card_id CHAR(12),
|
||||
square_payment_id TEXT,
|
||||
square_deposit_id CHAR(12),
|
||||
idempotency_key VARCHAR(64) UNIQUE,
|
||||
fees NUMERIC(10,2) DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by CHAR(12)
|
||||
@@ -392,6 +412,8 @@ CREATE TABLE payments (
|
||||
CREATE INDEX idx_payments_bookingid ON payments(booking_id);
|
||||
CREATE INDEX idx_payments_status ON payments(status);
|
||||
CREATE INDEX idx_payments_createdat ON payments(created_at);
|
||||
CREATE INDEX idx_payments_booking_id_status ON payments(booking_id, status);
|
||||
CREATE INDEX idx_payments_created_at_status ON payments(created_at, status);
|
||||
|
||||
-- =======================================
|
||||
-- LOYALTY REDEMPTIONS TABLE
|
||||
@@ -546,10 +568,8 @@ CREATE TABLE user_notification_preferences (
|
||||
|
||||
CREATE INDEX idx_user_notification_preferences_user_id ON user_notification_preferences(user_id);
|
||||
|
||||
CREATE INDEX idx_payments_booking_id_status ON payments(booking_id, status);
|
||||
CREATE INDEX idx_bookings_start_time_status ON bookings(start_time, status);
|
||||
CREATE INDEX idx_users_created_at ON users(created_at);
|
||||
CREATE INDEX idx_payments_created_at_status ON payments(created_at, status);
|
||||
|
||||
create table images (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
@@ -1293,6 +1313,78 @@ BEGIN
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- =======================================
|
||||
-- USER SAVED CARDS TABLE (Square Integration)
|
||||
-- =======================================
|
||||
|
||||
CREATE TABLE user_saved_cards (
|
||||
id CHAR(12) PRIMARY KEY DEFAULT generate_user_saved_card_id(),
|
||||
user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
square_card_id TEXT NOT NULL UNIQUE,
|
||||
brand TEXT NOT NULL,
|
||||
last_4 TEXT NOT NULL,
|
||||
exp_month INT NOT NULL,
|
||||
exp_year INT NOT NULL,
|
||||
fingerprint TEXT,
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
|
||||
retained_until TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_saved_cards_user ON user_saved_cards(user_id);
|
||||
CREATE INDEX idx_user_saved_cards_fingerprint ON user_saved_cards(fingerprint);
|
||||
CREATE INDEX idx_user_saved_cards_active ON user_saved_cards(user_id, deleted_at) WHERE deleted_at IS NULL;
|
||||
|
||||
-- =======================================
|
||||
-- REFUNDS TABLE
|
||||
-- =======================================
|
||||
|
||||
CREATE TABLE refunds (
|
||||
id CHAR(12) PRIMARY KEY DEFAULT generate_refund_id(),
|
||||
payment_id CHAR(12) NOT NULL REFERENCES payments(id) ON DELETE CASCADE,
|
||||
booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
|
||||
amount NUMERIC(10,2) NOT NULL CHECK (amount > 0),
|
||||
square_refund_id TEXT,
|
||||
status payment_status NOT NULL DEFAULT 'pending',
|
||||
reason TEXT NOT NULL,
|
||||
created_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_refunds_payment ON refunds(payment_id);
|
||||
CREATE INDEX idx_refunds_booking ON refunds(booking_id);
|
||||
|
||||
-- =======================================
|
||||
-- AFFILIATE PAYOUTS TABLE
|
||||
-- =======================================
|
||||
|
||||
CREATE TABLE affiliate_payouts (
|
||||
id CHAR(12) PRIMARY KEY DEFAULT generate_affiliate_payout_id(),
|
||||
affiliate_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
|
||||
amount NUMERIC(10,2) CHECK (amount >= 0),
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_affiliate_payouts_affiliate ON affiliate_payouts(affiliate_id);
|
||||
|
||||
-- =======================================
|
||||
-- SQUARE DEPOSITS TABLE (Bank Reconciliation)
|
||||
-- =======================================
|
||||
|
||||
CREATE TABLE square_deposits (
|
||||
id CHAR(12) PRIMARY KEY DEFAULT generate_square_deposit_id(),
|
||||
square_deposit_id TEXT,
|
||||
amount NUMERIC(10,2) NOT NULL,
|
||||
fees_deducted NUMERIC(10,2),
|
||||
deposited_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_square_deposits_deposited ON square_deposits(deposited_at);
|
||||
|
||||
-- =======================================
|
||||
-- FUNCTION USAGE SUMMARY
|
||||
-- =======================================
|
||||
|
||||
+5
-1
@@ -134,6 +134,10 @@ tmux set-environment -t $SESSION_NAME S3_SECRET_KEY "$S3_SECRET_KEY"
|
||||
tmux set-environment -t $SESSION_NAME S3_BUCKET "$S3_BUCKET"
|
||||
tmux set-environment -t $SESSION_NAME AWS_REGION "$AWS_REGION"
|
||||
tmux set-environment -t $SESSION_NAME VITE_BACKEND_URL "$VITE_BACKEND_URL"
|
||||
tmux set-environment -t $SESSION_NAME SQUARE_ACCESS_TOKEN "${SQUARE_ACCESS_TOKEN:-}"
|
||||
tmux set-environment -t $SESSION_NAME SQUARE_LOCATION_ID "${SQUARE_LOCATION_ID:-}"
|
||||
tmux set-environment -t $SESSION_NAME SQUARE_ENVIRONMENT "${SQUARE_ENVIRONMENT:-mock}"
|
||||
tmux set-environment -t $SESSION_NAME SQUARE_WEBHOOK_SIGNATURE_KEY "${SQUARE_WEBHOOK_SIGNATURE_KEY:-}"
|
||||
|
||||
# Pane 0: Database
|
||||
tmux send-keys -t $SESSION_NAME 'docker exec -it postgres psql -U myuser -d mydb -c "SELECT relname AS table_name, n_live_tup AS row_count FROM pg_stat_user_tables ORDER BY table_name;"'
|
||||
@@ -1045,7 +1049,7 @@ echo -e "${C_GREEN}⏳ Running tests...${C_RESET}"
|
||||
cd /home/popertots/Crussell/backend
|
||||
export POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_DB GO_TESTING=1
|
||||
TEST_OUTPUT_FILE=$(mktemp)
|
||||
go test -tags test -v -p 1 -count=1 ./... 2>&1 | tee "$TEST_OUTPUT_FILE" || true
|
||||
go test -tags "test,dev" -v -p 1 -count=1 ./... 2>&1 | tee "$TEST_OUTPUT_FILE" || true
|
||||
TEST_OUTPUT=$(cat "$TEST_OUTPUT_FILE")
|
||||
rm -f "$TEST_OUTPUT_FILE"
|
||||
cd ..
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"log": {
|
||||
"version": "1.2",
|
||||
"creator": {
|
||||
"name": "Zen",
|
||||
"version": "1.19.12b"
|
||||
},
|
||||
"browser": {
|
||||
"name": "Zen",
|
||||
"version": "1.19.12b"
|
||||
},
|
||||
"pages": [
|
||||
{
|
||||
"id": "page_1",
|
||||
"pageTimings": {
|
||||
"onContentLoad": -82849,
|
||||
"onLoad": -82148
|
||||
},
|
||||
"startedDateTime": "2026-05-17T17:55:39.809+01:00",
|
||||
"title": "http://192.168.1.135:5173/book"
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
{
|
||||
"startedDateTime": "2026-05-17T17:55:39.809+01:00",
|
||||
"request": {
|
||||
"bodySize": 87,
|
||||
"method": "POST",
|
||||
"url": "http://192.168.1.135:5173/api/bookings/reserve",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"headers": [
|
||||
{
|
||||
"name": "Host",
|
||||
"value": "192.168.1.135:5173"
|
||||
},
|
||||
{
|
||||
"name": "User-Agent",
|
||||
"value": "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
|
||||
},
|
||||
{
|
||||
"name": "Accept",
|
||||
"value": "*/*"
|
||||
},
|
||||
{
|
||||
"name": "Accept-Language",
|
||||
"value": "en-US,en;q=0.9"
|
||||
},
|
||||
{
|
||||
"name": "Accept-Encoding",
|
||||
"value": "gzip, deflate"
|
||||
},
|
||||
{
|
||||
"name": "Referer",
|
||||
"value": "http://192.168.1.135:5173/book"
|
||||
},
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "Content-Length",
|
||||
"value": "87"
|
||||
},
|
||||
{
|
||||
"name": "Origin",
|
||||
"value": "http://192.168.1.135:5173"
|
||||
},
|
||||
{
|
||||
"name": "Sec-GPC",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"name": "Connection",
|
||||
"value": "keep-alive"
|
||||
},
|
||||
{
|
||||
"name": "Priority",
|
||||
"value": "u=4"
|
||||
},
|
||||
{
|
||||
"name": "Pragma",
|
||||
"value": "no-cache"
|
||||
},
|
||||
{
|
||||
"name": "Cache-Control",
|
||||
"value": "no-cache"
|
||||
}
|
||||
],
|
||||
"cookies": [],
|
||||
"queryString": [],
|
||||
"headersSize": 449,
|
||||
"postData": {
|
||||
"mimeType": "application/json",
|
||||
"params": [],
|
||||
"text": "{\"start_time\":\"2026-05-21T16:00:00.000Z\",\"service_ids\":[\"cf628115690e\",\"543de40e5079\"]}"
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": 400,
|
||||
"statusText": "Bad Request",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"headers": [
|
||||
{
|
||||
"name": "Vary",
|
||||
"value": "Origin"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "67"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "text/plain; charset=utf-8"
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"value": "Sun, 17 May 2026 16:55:39 GMT"
|
||||
},
|
||||
{
|
||||
"name": "referrer-policy",
|
||||
"value": "strict-origin-when-cross-origin"
|
||||
},
|
||||
{
|
||||
"name": "strict-transport-security",
|
||||
"value": "max-age=31536000; includeSubDomains"
|
||||
},
|
||||
{
|
||||
"name": "x-content-type-options",
|
||||
"value": "nosniff"
|
||||
},
|
||||
{
|
||||
"name": "x-frame-options",
|
||||
"value": "DENY"
|
||||
},
|
||||
{
|
||||
"name": "x-xss-protection",
|
||||
"value": "1; mode=block"
|
||||
},
|
||||
{
|
||||
"name": "Connection",
|
||||
"value": "keep-alive"
|
||||
},
|
||||
{
|
||||
"name": "Keep-Alive",
|
||||
"value": "timeout=5"
|
||||
}
|
||||
],
|
||||
"cookies": [],
|
||||
"content": {
|
||||
"mimeType": "text/plain; charset=utf-8",
|
||||
"size": 67,
|
||||
"text": "Cannot book this time - services would extend beyond closing hours\n"
|
||||
},
|
||||
"redirectURL": "",
|
||||
"headersSize": 390,
|
||||
"bodySize": 457
|
||||
},
|
||||
"cache": {},
|
||||
"timings": {
|
||||
"blocked": -1,
|
||||
"dns": 0,
|
||||
"connect": 0,
|
||||
"ssl": 0,
|
||||
"send": 0,
|
||||
"wait": 7,
|
||||
"receive": 0
|
||||
},
|
||||
"time": 7,
|
||||
"_securityState": "insecure",
|
||||
"serverIPAddress": "192.168.1.135",
|
||||
"connection": "5173",
|
||||
"pageref": "page_1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user