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_BUCKET=crussell
|
||||||
# R2_PUBLIC_URL=https://pub-<your-domain>.r2.dev
|
# R2_PUBLIC_URL=https://pub-<your-domain>.r2.dev
|
||||||
# AWS_REGION=auto
|
# 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)
|
defer db.DB.Exec(context.Background(), "DELETE FROM exceptional_working_hours_groups WHERE id = $1", groupID)
|
||||||
|
|
||||||
// Add closed hours for targetDate (closed all day)
|
// 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(), `
|
_, err = db.DB.Exec(context.Background(), `
|
||||||
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
|
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("failed to create holiday hours: %v", err)
|
t.Fatalf("failed to create holiday hours: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply the group to the week containing targetDate
|
// Apply the group to the week containing targetDate
|
||||||
// Must use Monday of that week (matching handler logic)
|
// Must use Monday of that week (matching handler logic)
|
||||||
targetWeekday := int(targetDate.Weekday())
|
daysToMonday := int(targetDate.Weekday())
|
||||||
if targetWeekday == 0 {
|
if daysToMonday == 0 {
|
||||||
targetWeekday = 7 // Sunday -> 7
|
daysToMonday = 7
|
||||||
}
|
}
|
||||||
mondayOfWeek := targetDate.AddDate(0, 0, -targetWeekday+1)
|
mondayOfWeek := targetDate.AddDate(0, 0, -daysToMonday+1)
|
||||||
_, err = db.DB.Exec(context.Background(), `
|
_, err = db.DB.Exec(context.Background(), `
|
||||||
INSERT INTO exceptional_group_applications (group_id, week_start)
|
INSERT INTO exceptional_group_applications (group_id, week_start)
|
||||||
VALUES ($1, $2)
|
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)
|
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
|
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 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) {
|
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)
|
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
|
||||||
|
localEnd := localStart.Add(time.Duration(svcDuration) * time.Minute)
|
||||||
closeTime, _ := time.Parse("15:04:05", closeStr)
|
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)
|
http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1588,7 +1588,9 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
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 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)
|
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)
|
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
|
||||||
|
localEnd := localStart.Add(time.Duration(svcDuration) * time.Minute)
|
||||||
closeTime, _ := time.Parse("15:04:05", closeStr)
|
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)
|
http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1832,9 +1835,10 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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")
|
bookingTime := req.StartTime.Format("15:04:05")
|
||||||
daysToMonday := weekday
|
daysToMonday := int(req.StartTime.Weekday())
|
||||||
if daysToMonday == 0 {
|
if daysToMonday == 0 {
|
||||||
daysToMonday = 7
|
daysToMonday = 7
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1651,9 +1651,8 @@ func TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3(t *testing.T) {
|
|||||||
|
|
||||||
token := jwt.GenerateUserToken(userID)
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
// Create booking with start_time = now + 12 hours (< 24h notice)
|
// Create booking with start_time = now + 23 hours (< 24h notice, > 1h advance)
|
||||||
soonTime := time.Now().Add(12 * time.Hour).Truncate(time.Second)
|
soonTime := time.Now().Add(23 * time.Hour).Truncate(time.Second)
|
||||||
soonTime = time.Date(soonTime.Year(), soonTime.Month(), soonTime.Day(), 10, 0, 0, 0, soonTime.Location())
|
|
||||||
|
|
||||||
bookingReq := CreateBookingRequest{
|
bookingReq := CreateBookingRequest{
|
||||||
StartTime: soonTime,
|
StartTime: soonTime,
|
||||||
@@ -1818,9 +1817,8 @@ func TestDeleteBooking_NoShowWithForgiveness_NoPenalty(t *testing.T) {
|
|||||||
|
|
||||||
token := jwt.GenerateUserToken(userID)
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
// Create booking with start_time = now + 12 hours (< 24h notice)
|
// Create booking with start_time = now + 23 hours (< 24h notice, > 1h advance)
|
||||||
soonTime := time.Now().Add(12 * time.Hour).Truncate(time.Second)
|
soonTime := time.Now().Add(23 * time.Hour).Truncate(time.Second)
|
||||||
soonTime = time.Date(soonTime.Year(), soonTime.Month(), soonTime.Day(), 10, 0, 0, 0, soonTime.Location())
|
|
||||||
|
|
||||||
bookingReq := CreateBookingRequest{
|
bookingReq := CreateBookingRequest{
|
||||||
StartTime: soonTime,
|
StartTime: soonTime,
|
||||||
@@ -1903,8 +1901,7 @@ func TestDeleteBooking_SecondNoShow_StaysAt3(t *testing.T) {
|
|||||||
token := jwt.GenerateUserToken(userID)
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
// === First booking: no-show ===
|
// === First booking: no-show ===
|
||||||
soonTime1 := time.Now().Add(12 * time.Hour).Truncate(time.Second)
|
soonTime1 := time.Now().Add(23 * time.Hour).Truncate(time.Second)
|
||||||
soonTime1 = time.Date(soonTime1.Year(), soonTime1.Month(), soonTime1.Day(), 10, 0, 0, 0, soonTime1.Location())
|
|
||||||
|
|
||||||
bookingReq1 := CreateBookingRequest{
|
bookingReq1 := CreateBookingRequest{
|
||||||
StartTime: soonTime1,
|
StartTime: soonTime1,
|
||||||
@@ -1944,10 +1941,7 @@ func TestDeleteBooking_SecondNoShow_StaysAt3(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// === Second booking: no-show ===
|
// === Second booking: no-show ===
|
||||||
// Need to wait a bit or create with different time to avoid conflict
|
soonTime2 := time.Now().Add(47 * time.Hour).Truncate(time.Second)
|
||||||
// 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())
|
|
||||||
|
|
||||||
bookingReq2 := CreateBookingRequest{
|
bookingReq2 := CreateBookingRequest{
|
||||||
StartTime: soonTime2,
|
StartTime: soonTime2,
|
||||||
@@ -4149,8 +4143,9 @@ func TestBookings_Edit_ClosedDay_UserBlocked(t *testing.T) {
|
|||||||
|
|
||||||
// Calculate week start for the booking target date
|
// Calculate week start for the booking target date
|
||||||
targetDate := time.Now().Add(96 * time.Hour)
|
targetDate := time.Now().Add(96 * time.Hour)
|
||||||
weekday := int(targetDate.Weekday())
|
// DB convention: 0=Monday..6=Sunday; Go: 0=Sunday..6=Saturday. Convert.
|
||||||
daysToMonday := weekday
|
dbWeekday := (int(targetDate.Weekday()) + 6) % 7
|
||||||
|
daysToMonday := int(targetDate.Weekday())
|
||||||
if daysToMonday == 0 {
|
if daysToMonday == 0 {
|
||||||
daysToMonday = 7
|
daysToMonday = 7
|
||||||
}
|
}
|
||||||
@@ -4169,7 +4164,7 @@ func TestBookings_Edit_ClosedDay_UserBlocked(t *testing.T) {
|
|||||||
_, err = db.DB.Exec(context.Background(),
|
_, err = db.DB.Exec(context.Background(),
|
||||||
`INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
|
`INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
|
||||||
VALUES ($1, $2, '08:00:00', '20:00:00', false)`,
|
VALUES ($1, $2, '08:00:00', '20:00:00', false)`,
|
||||||
groupID, weekday)
|
groupID, dbWeekday)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create closed exceptional hours: %v", err)
|
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)
|
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
|
// 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")
|
bookingTime := req.StartTime.Format("15:04:05")
|
||||||
daysToMonday := weekday
|
daysToMonday := int(req.StartTime.Weekday())
|
||||||
if daysToMonday == 0 {
|
if daysToMonday == 0 {
|
||||||
daysToMonday = 7
|
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
|
// Check if booking time falls within a closed exceptional hours period
|
||||||
// Calculate the Monday of the week containing the booking date
|
// Calculate the Monday of the week containing the booking date
|
||||||
weekday := int(req.StartTime.Weekday())
|
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
|
||||||
daysToMonday := weekday
|
weekday := int((req.StartTime.Weekday() + 6) % 7)
|
||||||
|
daysToMonday := int(req.StartTime.Weekday())
|
||||||
if daysToMonday == 0 {
|
if daysToMonday == 0 {
|
||||||
daysToMonday = 7 // Sunday -> next Monday
|
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
|
// 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
|
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 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)
|
log.Printf("Failed to get hours: %v", err)
|
||||||
@@ -114,14 +116,15 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
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)
|
http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// g. Check existing booking overlap (same query as CreateBookingHandler)
|
// g. Check existing booking overlap (same query as CreateBookingHandler)
|
||||||
|
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
|
||||||
var cnt int
|
var cnt int
|
||||||
db.DB.QueryRow(r.Context(), `
|
db.DB.QueryRow(r.Context(), `
|
||||||
SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','in_progress','completed')
|
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")
|
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/auth"
|
||||||
"crussell/internal/dav"
|
"crussell/internal/dav"
|
||||||
"crussell/internal/s3"
|
"crussell/internal/s3"
|
||||||
|
"crussell/internal/square"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -24,11 +25,13 @@ import (
|
|||||||
"crussell/handlers/admin"
|
"crussell/handlers/admin"
|
||||||
"crussell/handlers/bookings"
|
"crussell/handlers/bookings"
|
||||||
"crussell/handlers/notifications"
|
"crussell/handlers/notifications"
|
||||||
|
"crussell/handlers/payments"
|
||||||
"crussell/handlers/portfolio"
|
"crussell/handlers/portfolio"
|
||||||
"crussell/handlers/scheduling"
|
"crussell/handlers/scheduling"
|
||||||
"crussell/handlers/services"
|
"crussell/handlers/services"
|
||||||
"crussell/handlers/today"
|
"crussell/handlers/today"
|
||||||
"crussell/handlers/user"
|
"crussell/handlers/user"
|
||||||
|
"crussell/handlers/webhooks"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
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) {
|
func healthCheckHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
status := "ok"
|
status := "ok"
|
||||||
services := map[string]string{
|
services := map[string]string{
|
||||||
@@ -101,6 +109,7 @@ func main() {
|
|||||||
initDB()
|
initDB()
|
||||||
initDav()
|
initDav()
|
||||||
initS3()
|
initS3()
|
||||||
|
initSquare()
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
@@ -218,6 +227,13 @@ func main() {
|
|||||||
r.Delete("/bookings/{id}", bookings.DeleteBookingHandler)
|
r.Delete("/bookings/{id}", bookings.DeleteBookingHandler)
|
||||||
r.Post("/bookings/{id}/edit-request", bookings.RequestEditHandler)
|
r.Post("/bookings/{id}/edit-request", bookings.RequestEditHandler)
|
||||||
r.Delete("/bookings/{id}/edit-request", bookings.DeleteEditRequestHandler)
|
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)
|
// 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.Delete("/{id}", admin.DeleteDiscountCampaign)
|
||||||
r.Get("/{id}/stats", admin.GetCampaignStats)
|
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{
|
srv := &http.Server{
|
||||||
Addr: ":8080",
|
Addr: ":8080",
|
||||||
Handler: r,
|
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)
|
_, err := pool.Exec(ctx, "DELETE FROM time_blockers WHERE id = $1", blockerID)
|
||||||
return err
|
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_notification_preferences",
|
||||||
"user_referrals",
|
"user_referrals",
|
||||||
"booking_services",
|
"booking_services",
|
||||||
|
"refunds",
|
||||||
"payments",
|
"payments",
|
||||||
|
"user_saved_cards",
|
||||||
|
"square_deposits",
|
||||||
|
"affiliate_payouts",
|
||||||
"bookings",
|
"bookings",
|
||||||
"user_patch_tests",
|
"user_patch_tests",
|
||||||
"patch_tests",
|
"patch_tests",
|
||||||
@@ -202,7 +206,11 @@ func TruncateTables(t *testing.T, pool *pgxpool.Pool) {
|
|||||||
"user_social_logins",
|
"user_social_logins",
|
||||||
"verification_codes",
|
"verification_codes",
|
||||||
"booking_services",
|
"booking_services",
|
||||||
|
"refunds",
|
||||||
"payments",
|
"payments",
|
||||||
|
"user_saved_cards",
|
||||||
|
"square_deposits",
|
||||||
|
"affiliate_payouts",
|
||||||
"bookings",
|
"bookings",
|
||||||
"booking_edit_requests",
|
"booking_edit_requests",
|
||||||
"user_patch_tests",
|
"user_patch_tests",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import * as Textarea from '$lib/components/ui/textarea';
|
import * as Textarea from '$lib/components/ui/textarea';
|
||||||
import * as Label from '$lib/components/ui/label';
|
import * as Label from '$lib/components/ui/label';
|
||||||
import DatePicker from '$lib/components/booking/DatePicker.svelte';
|
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 type { Booking, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
|
||||||
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
|
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
|
||||||
|
|
||||||
@@ -94,6 +95,25 @@
|
|||||||
.reduce((sum, p) => sum + p.amount, 0) || 0
|
.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(
|
let isRescheduleValid = $derived(
|
||||||
rescheduleDate && rescheduleTime && rescheduleTime.length >= 4
|
rescheduleDate && rescheduleTime && rescheduleTime.length >= 4
|
||||||
);
|
);
|
||||||
@@ -847,12 +867,37 @@
|
|||||||
Add to Calendar
|
Add to Calendar
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
|
<Button size="sm" class="w-full" variant="ghost" onclick={() => (open = false)}>Close</Button>
|
||||||
</div>
|
</div>
|
||||||
</Modal.Content>
|
</Modal.Content>
|
||||||
</Modal.Root>
|
</Modal.Root>
|
||||||
|
|
||||||
|
{#if showPaymentModal && selectedBooking}
|
||||||
|
<PaymentModal
|
||||||
|
booking={selectedBooking}
|
||||||
|
onClose={() => (showPaymentModal = false)}
|
||||||
|
onComplete={handlePaymentComplete}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Modal.Root open={showCancelConfirm} onOpenChange={(v) => (showCancelConfirm = v)}>
|
<Modal.Root open={showCancelConfirm} onOpenChange={(v) => (showCancelConfirm = v)}>
|
||||||
<Modal.Content class="max-w-sm">
|
<Modal.Content class="max-w-sm">
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
|
|||||||
@@ -39,6 +39,13 @@
|
|||||||
let availableServices = $state<Service[]>([]);
|
let availableServices = $state<Service[]>([]);
|
||||||
let loadingServices = $state(false);
|
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(() => {
|
$effect(() => {
|
||||||
if (open && bookingId) {
|
if (open && bookingId) {
|
||||||
fetchBooking();
|
fetchBooking();
|
||||||
@@ -331,6 +338,55 @@
|
|||||||
saving = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<Modal.Root bind:open>
|
<Modal.Root bind:open>
|
||||||
@@ -473,6 +529,57 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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 -->
|
<!-- Notes -->
|
||||||
<div>
|
<div>
|
||||||
<label for="edit-notes" class="mb-2 block text-sm font-medium">Notes</label>
|
<label for="edit-notes" class="mb-2 block text-sm font-medium">Notes</label>
|
||||||
@@ -665,6 +772,59 @@
|
|||||||
</Modal.Content>
|
</Modal.Content>
|
||||||
</Modal.Root>
|
</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>
|
<style>
|
||||||
:global(input[type='number']) {
|
:global(input[type='number']) {
|
||||||
-moz-appearance: textfield;
|
-moz-appearance: textfield;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { Label } from '$lib/components/ui/label/index.js';
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||||
import { Separator } from '$lib/components/ui/separator/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
|
// 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
|
// 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.
|
// auto-adjust for international timezones — the slot time shown is the actual UK salon time.
|
||||||
@@ -50,6 +51,185 @@
|
|||||||
let isSubmitting = $state(false);
|
let isSubmitting = $state(false);
|
||||||
let idempotencyKey = $state<string>('');
|
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 ===============
|
// =============== Slot Reservation System ===============
|
||||||
let reservationId = $state<string | null>(null);
|
let reservationId = $state<string | null>(null);
|
||||||
let reservationExpiresAt = $state<Date | null>(null);
|
let reservationExpiresAt = $state<Date | null>(null);
|
||||||
@@ -500,7 +680,10 @@
|
|||||||
function generateGroupedTimeSlots(
|
function generateGroupedTimeSlots(
|
||||||
duration: number,
|
duration: number,
|
||||||
date: CalendarDate | undefined,
|
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<{
|
): Array<{
|
||||||
type: 'available' | 'unavailable';
|
type: 'available' | 'unavailable';
|
||||||
startTime: string;
|
startTime: string;
|
||||||
@@ -550,13 +733,17 @@
|
|||||||
const minute = minutes % 60;
|
const minute = minutes % 60;
|
||||||
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
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 (isAvailable) {
|
||||||
if (currentUnavailableStart !== null) {
|
if (currentUnavailableStart !== null) {
|
||||||
const groupEndTime = calculatePreviousTime(timeStr);
|
const groupEndTime = calculatePreviousTime(timeStr);
|
||||||
const unavailableStartTime = currentUnavailableStart || lastAvailableEndTime;
|
const unavailableStartTime = currentUnavailableStart || lastAvailableEndTime;
|
||||||
if (unavailableStartTime && timeToMinutes(unavailableStartTime) < timeToMinutes(groupEndTime)) {
|
if (
|
||||||
|
unavailableStartTime &&
|
||||||
|
timeToMinutes(unavailableStartTime) < timeToMinutes(groupEndTime)
|
||||||
|
) {
|
||||||
groupedSlots.push({
|
groupedSlots.push({
|
||||||
type: 'unavailable',
|
type: 'unavailable',
|
||||||
startTime: unavailableStartTime,
|
startTime: unavailableStartTime,
|
||||||
@@ -698,10 +885,10 @@
|
|||||||
const selStart = selHour * 60 + selMinute;
|
const selStart = selHour * 60 + selMinute;
|
||||||
const selEnd = selStart + duration;
|
const selEnd = selStart + duration;
|
||||||
|
|
||||||
const stillAvailable = dayAvailable.some(slot => {
|
const stillAvailable = dayAvailable.some((slot) => {
|
||||||
const [sH, sM] = slot.startTime.split(':').map(Number);
|
const [sH, sM] = slot.startTime.split(':').map(Number);
|
||||||
const [eH, eM] = slot.endTime.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) {
|
if (!stillAvailable) {
|
||||||
@@ -768,6 +955,22 @@
|
|||||||
if (!reserved) return;
|
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) {
|
if (currentStep < 5) {
|
||||||
currentStep++;
|
currentStep++;
|
||||||
setTimeout(() => {
|
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() {
|
function prevStep() {
|
||||||
if (currentStep > 1) {
|
if (currentStep > 1) {
|
||||||
currentStep--;
|
currentStep--;
|
||||||
@@ -804,153 +1153,6 @@
|
|||||||
)) && !reservationExpired
|
)) && !reservationExpired
|
||||||
);
|
);
|
||||||
const canProceedStep4 = $derived(true);
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-4xl p-6">
|
<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>
|
<p class="text-gray-600">Professional beauty treatments in a calm and friendly environment</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StepIndicator {currentStep} steps={['Service', 'Date & Time', 'Details', 'Payment & Review', 'Confirm']} />
|
<StepIndicator
|
||||||
|
{currentStep}
|
||||||
|
steps={['Service', 'Date & Time', 'Details', 'Payment', 'Confirmation']}
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Step 1: Service Selection -->
|
<!-- Step 1: Service Selection -->
|
||||||
{#if currentStep === 1}
|
{#if currentStep === 1}
|
||||||
@@ -969,6 +1174,51 @@
|
|||||||
<Card.Description>Select one or more treatments for your appointment</Card.Description>
|
<Card.Description>Select one or more treatments for your appointment</Card.Description>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="space-y-4">
|
<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
|
<ServiceSelector
|
||||||
{services}
|
{services}
|
||||||
selected={selectedServices}
|
selected={selectedServices}
|
||||||
@@ -1197,8 +1447,8 @@
|
|||||||
appointment reminders via email and/or SMS.
|
appointment reminders via email and/or SMS.
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 text-xs text-gray-500">
|
<p class="mt-2 text-xs text-gray-500">
|
||||||
<strong>Cancellation Policy:</strong> Free cancellation up to 24 hours before your
|
<strong>Cancellation Policy:</strong> Free cancellation up to 24 hours before your appointment.
|
||||||
appointment. Cancellations within 24 hours may incur a deposit penalty.
|
Cancellations within 24 hours may incur a deposit penalty.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
@@ -1215,12 +1465,15 @@
|
|||||||
</Card.Root>
|
</Card.Root>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Step 4: Payment & Review -->
|
<!-- Step 4: Deposit Payment (only shown if deposit required) -->
|
||||||
{#if currentStep === 4}
|
{#if currentStep === 4}
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>Payment & Review</Card.Title>
|
<Card.Title>Pay Your Deposit</Card.Title>
|
||||||
<Card.Description>Review your booking details</Card.Description>
|
<Card.Description>
|
||||||
|
A deposit of <span class="font-semibold">£{calculateDepositAmount()}</span> is required to secure
|
||||||
|
your appointment.
|
||||||
|
</Card.Description>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="space-y-6">
|
<Card.Content class="space-y-6">
|
||||||
<BookingSummary
|
<BookingSummary
|
||||||
@@ -1239,11 +1492,147 @@
|
|||||||
showCustomer={true}
|
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. -->
|
{#if !showPaymentForm}
|
||||||
<div class="rounded-lg bg-white p-6">
|
<div class="rounded-lg border border-amber-200 bg-amber-50 p-6">
|
||||||
<h2 class="mb-4 text-2xl font-semibold">Payment</h2>
|
<h3 class="mb-2 text-lg font-semibold text-amber-800">Deposit Required</h3>
|
||||||
<p class="text-gray-600">Square payment integration will be added here.</p>
|
<p class="mb-4 text-amber-700">
|
||||||
</div>
|
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.Content>
|
||||||
<Card.Footer class="flex justify-between">
|
<Card.Footer class="flex justify-between">
|
||||||
<Button variant="outline" onclick={prevStep}>Back</Button>
|
<Button variant="outline" onclick={prevStep}>Back</Button>
|
||||||
@@ -1252,36 +1641,158 @@
|
|||||||
onclick={nextStep}
|
onclick={nextStep}
|
||||||
class="bg-primary text-primary-foreground"
|
class="bg-primary text-primary-foreground"
|
||||||
>
|
>
|
||||||
Next: Confirm
|
{isSubmitting ? 'Processing...' : 'Skip Payment'}
|
||||||
</Button>
|
</Button>
|
||||||
</Card.Footer>
|
</Card.Footer>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Step 5: Confirm -->
|
<!-- Step 5: Confirmation -->
|
||||||
{#if currentStep === 5}
|
{#if currentStep === 5}
|
||||||
<Card.Root>
|
{#if confirmedBooking}
|
||||||
<Card.Header>
|
{@const isRequested = confirmedBooking.notes && confirmedBooking.notes.length > 0}
|
||||||
<Card.Title>Confirm Your Booking</Card.Title>
|
{@const bookingDate = new SvelteDate(confirmedBooking.start_time)}
|
||||||
<Card.Description>Ready to confirm your appointment</Card.Description>
|
{@const dateStr = bookingDate.toLocaleDateString('en-GB', {
|
||||||
</Card.Header>
|
weekday: 'long',
|
||||||
<Card.Content class="space-y-6">
|
day: 'numeric',
|
||||||
<div class="rounded-lg bg-gray-50 p-6 text-center">
|
month: 'long',
|
||||||
<p class="text-lg">
|
year: 'numeric'
|
||||||
Ready to confirm: {selectedServices.map((s) => s.name).join(', ')} on {selectedDate?.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })} at {selectedTime}
|
})}
|
||||||
</p>
|
{@const timeStr = bookingDate.toLocaleTimeString('en-GB', {
|
||||||
</div>
|
hour: 'numeric',
|
||||||
</Card.Content>
|
minute: '2-digit',
|
||||||
<Card.Footer class="flex justify-between">
|
hour12: true
|
||||||
<Button variant="outline" onclick={prevStep}>Back</Button>
|
})}
|
||||||
<Button
|
|
||||||
disabled={isSubmitting}
|
<Card.Root class="border-emerald-200">
|
||||||
onclick={submitBooking}
|
<Card.Header class="text-center">
|
||||||
class="bg-primary text-primary-foreground"
|
<div
|
||||||
>
|
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full {isRequested
|
||||||
{isSubmitting ? 'Processing...' : 'Confirm Booking'}
|
? 'bg-amber-100'
|
||||||
</Button>
|
: 'bg-emerald-100'}"
|
||||||
</Card.Footer>
|
>
|
||||||
</Card.Root>
|
<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 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>
|
||||||
|
{/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
|
||||||
|
onclick={() =>
|
||||||
|
(window.location.href = `/booking-confirmed/${confirmedBooking!.id}`)}
|
||||||
|
class="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
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}
|
{/if}
|
||||||
</div>
|
</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 { Button } from '$lib/components/ui/button';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||||
|
import PaymentModal from '$lib/components/payments/PaymentModal.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
openBookingModal: (bookingId: string) => void;
|
openBookingModal: (bookingId: string) => void;
|
||||||
@@ -44,6 +45,7 @@
|
|||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let timeRemaining = $state(0); // minutes remaining in current appointment
|
let timeRemaining = $state(0); // minutes remaining in current appointment
|
||||||
let isInProgress = $state(false);
|
let isInProgress = $state(false);
|
||||||
|
let showPaymentModal = $state(false);
|
||||||
|
|
||||||
// Calculate time remaining and free time
|
// Calculate time remaining and free time
|
||||||
let interval: ReturnType<typeof setInterval> | null = null;
|
let interval: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -163,7 +165,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleTakePayment() {
|
function handleTakePayment() {
|
||||||
toast.info('Take payment - Coming soon');
|
showPaymentModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePaymentComplete(payment: unknown) {
|
||||||
|
toast.success('Payment completed');
|
||||||
|
showPaymentModal = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCancel() {
|
function handleCancel() {
|
||||||
@@ -425,3 +432,7 @@
|
|||||||
</Card.Content>
|
</Card.Content>
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Root>
|
</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_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_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_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;
|
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_rate NUMERIC(5,2),
|
||||||
vat_amount NUMERIC(10,2),
|
vat_amount NUMERIC(10,2),
|
||||||
net_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(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
created_by CHAR(12)
|
created_by CHAR(12)
|
||||||
@@ -392,6 +412,8 @@ CREATE TABLE payments (
|
|||||||
CREATE INDEX idx_payments_bookingid ON payments(booking_id);
|
CREATE INDEX idx_payments_bookingid ON payments(booking_id);
|
||||||
CREATE INDEX idx_payments_status ON payments(status);
|
CREATE INDEX idx_payments_status ON payments(status);
|
||||||
CREATE INDEX idx_payments_createdat ON payments(created_at);
|
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
|
-- 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_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_bookings_start_time_status ON bookings(start_time, status);
|
||||||
CREATE INDEX idx_users_created_at ON users(created_at);
|
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 (
|
create table images (
|
||||||
id uuid primary key default gen_random_uuid(),
|
id uuid primary key default gen_random_uuid(),
|
||||||
@@ -1293,6 +1313,78 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ 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
|
-- 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 S3_BUCKET "$S3_BUCKET"
|
||||||
tmux set-environment -t $SESSION_NAME AWS_REGION "$AWS_REGION"
|
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 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
|
# 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;"'
|
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
|
cd /home/popertots/Crussell/backend
|
||||||
export POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_DB GO_TESTING=1
|
export POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_DB GO_TESTING=1
|
||||||
TEST_OUTPUT_FILE=$(mktemp)
|
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")
|
TEST_OUTPUT=$(cat "$TEST_OUTPUT_FILE")
|
||||||
rm -f "$TEST_OUTPUT_FILE"
|
rm -f "$TEST_OUTPUT_FILE"
|
||||||
cd ..
|
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