feat(backend): update payments handlers and service

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-18 16:26:29 +01:00
co-authored by Sisyphus
parent 81e2005114
commit 31cc3ef5c3
6 changed files with 1669 additions and 168 deletions
+77 -64
View File
@@ -23,15 +23,15 @@ import (
// --- Types --- // --- Types ---
type GiftCard struct { type GiftCard struct {
ID string `json:"id"` ID string `json:"id"`
TotalFundsAdded float64 `json:"total_funds_added"` TotalFundsAdded float64 `json:"total_funds_added"`
AmountRemaining float64 `json:"amount_remaining"` AmountRemaining float64 `json:"amount_remaining"`
CreatedBy *string `json:"created_by,omitempty"` CreatedBy *string `json:"created_by,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
RedeemedAt *time.Time `json:"redeemed_at,omitempty"` RedeemedAt *time.Time `json:"redeemed_at,omitempty"`
RedeemedBy *string `json:"redeemed_by,omitempty"` RedeemedBy *string `json:"redeemed_by,omitempty"`
IsInventory bool `json:"is_inventory"` IsInventory bool `json:"is_inventory"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"` LastUsedAt *time.Time `json:"last_used_at,omitempty"`
} }
type UserBalance struct { type UserBalance struct {
@@ -52,6 +52,7 @@ type GiftCardListResponse struct {
Page int `json:"page"` Page int `json:"page"`
PerPage int `json:"perPage"` PerPage int `json:"perPage"`
TotalPages int `json:"totalPages"` TotalPages int `json:"totalPages"`
NextCursor *string `json:"next_cursor,omitempty"`
} }
type CreateGiftCardRequest struct { type CreateGiftCardRequest struct {
@@ -93,14 +94,8 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
searchTerm := query.Get("q") searchTerm := query.Get("q")
// Pagination parameters // Pagination parameters
page := 1
perPage := 10 perPage := 10
cursorStr := query.Get("cursor")
if pageStr := query.Get("page"); pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
}
if perPageStr := query.Get("per_page"); perPageStr != "" { if perPageStr := query.Get("per_page"); perPageStr != "" {
if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 { if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 {
@@ -108,7 +103,13 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
} }
} }
offset := (page - 1) * perPage // Accept page param for backward compat (deprecated)
page := 1
if pageStr := query.Get("page"); pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
}
var resp GiftCardListResponse var resp GiftCardListResponse
resp.GiftCards = []GiftCard{} resp.GiftCards = []GiftCard{}
@@ -158,33 +159,41 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
} }
var gcTotal int var gcTotal int
var gcCountArgs []interface{}
var gcListArgs []interface{} var gcListArgs []interface{}
gcCountQuery := fmt.Sprintf(`SELECT COUNT(*) FROM gift_cards %s`, whereSQL)
gcListQuery := fmt.Sprintf(` gcListQuery := fmt.Sprintf(`
SELECT id, total_funds_added, amount_remaining, created_by, created_at, redeemed_at, redeemed_by, is_inventory, last_used_at SELECT id, total_funds_added, amount_remaining, created_at, is_inventory
FROM gift_cards FROM gift_cards
%s %s
ORDER BY created_at DESC
LIMIT $%%d OFFSET $%%d
`, whereSQL) `, whereSQL)
if searchTerm != "" { if searchTerm != "" {
searchPattern := "%" + searchTerm + "%" searchPattern := "%" + searchTerm + "%"
gcCountArgs = []interface{}{searchPattern} gcListArgs = []interface{}{searchPattern}
gcListArgs = []interface{}{searchPattern, perPage, offset}
gcListQuery = fmt.Sprintf(gcListQuery, 2, 3)
} else {
gcListArgs = []interface{}{perPage, offset}
gcListQuery = fmt.Sprintf(gcListQuery, 1, 2)
}
err = db.DB.QueryRow(ctx, gcCountQuery, gcCountArgs...).Scan(&gcTotal) if cursorStr != "" {
if err != nil { cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
log.Printf("Failed to count gift cards: %v", err) if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError) http.Error(w, "invalid cursor: "+err.Error(), http.StatusBadRequest)
return return
}
gcListQuery += " AND (created_at, id) < ($2, $3)"
gcListArgs = append(gcListArgs, cursorCreatedAt, cursorID)
}
gcListQuery += " ORDER BY created_at DESC, id DESC LIMIT $" + strconv.Itoa(len(gcListArgs)+1)
gcListArgs = append(gcListArgs, perPage+1)
} else {
if cursorStr != "" {
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
if err != nil {
http.Error(w, "invalid cursor: "+err.Error(), http.StatusBadRequest)
return
}
gcListQuery += " WHERE (created_at, id) < ($1, $2)"
gcListArgs = append(gcListArgs, cursorCreatedAt, cursorID)
}
gcListQuery += " ORDER BY created_at DESC, id DESC LIMIT $" + strconv.Itoa(len(gcListArgs)+1)
gcListArgs = append(gcListArgs, perPage+1)
} }
gcRows, err := db.DB.Query(ctx, gcListQuery, gcListArgs...) gcRows, err := db.DB.Query(ctx, gcListQuery, gcListArgs...)
@@ -195,21 +204,27 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
} }
defer gcRows.Close() defer gcRows.Close()
// Count query: total matching gift cards (same WHERE, without cursor/ORDER BY/LIMIT).
gcTotal = 0
if whereSQL != "" {
countArgs := []interface{}{}
if searchTerm != "" {
countArgs = append(countArgs, "%"+searchTerm+"%")
}
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards "+whereSQL, countArgs...).Scan(&gcTotal)
} else {
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards").Scan(&gcTotal)
}
for gcRows.Next() { for gcRows.Next() {
var gc GiftCard var gc GiftCard
var createdBy, redeemedBy sql.NullString
var redeemedAt, lastUsedAt sql.NullTime
err = gcRows.Scan( err = gcRows.Scan(
&gc.ID, &gc.ID,
&gc.TotalFundsAdded, &gc.TotalFundsAdded,
&gc.AmountRemaining, &gc.AmountRemaining,
&createdBy,
&gc.CreatedAt, &gc.CreatedAt,
&redeemedAt,
&redeemedBy,
&gc.IsInventory, &gc.IsInventory,
&lastUsedAt,
) )
if err != nil { if err != nil {
log.Printf("Failed to scan gift card: %v", err) log.Printf("Failed to scan gift card: %v", err)
@@ -217,19 +232,6 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
return return
} }
if createdBy.Valid {
gc.CreatedBy = &createdBy.String
}
if redeemedBy.Valid {
gc.RedeemedBy = &redeemedBy.String
}
if redeemedAt.Valid {
gc.RedeemedAt = &redeemedAt.Time
}
if lastUsedAt.Valid {
gc.LastUsedAt = &lastUsedAt.Time
}
resp.GiftCards = append(resp.GiftCards, gc) resp.GiftCards = append(resp.GiftCards, gc)
} }
@@ -243,7 +245,7 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
searchPattern := "%" + searchTerm + "%" searchPattern := "%" + searchTerm + "%"
ubListQuery = ` ubListQuery = `
SELECT COUNT(*) OVER() AS total_count, SELECT
b.user_id, u.n_first_name || ' ' || u.n_last_name AS name, u.email, b.balance, b.updated_at b.user_id, u.n_first_name || ' ' || u.n_last_name AS name, u.email, b.balance, b.updated_at
FROM user_giftcard_balances b FROM user_giftcard_balances b
JOIN users u ON b.user_id = u.id JOIN users u ON b.user_id = u.id
@@ -255,7 +257,7 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
ubListArgs = []interface{}{searchPattern} ubListArgs = []interface{}{searchPattern}
} else { } else {
ubListQuery = ` ubListQuery = `
SELECT COUNT(*) OVER() AS total_count, SELECT
b.user_id, u.n_first_name || ' ' || u.n_last_name AS name, u.email, b.balance, b.updated_at b.user_id, u.n_first_name || ' ' || u.n_last_name AS name, u.email, b.balance, b.updated_at
FROM user_giftcard_balances b FROM user_giftcard_balances b
JOIN users u ON b.user_id = u.id JOIN users u ON b.user_id = u.id
@@ -272,11 +274,18 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
} }
defer ubRows.Close() defer ubRows.Close()
// Count query for user balances.
if searchTerm != "" {
db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM user_giftcard_balances b
JOIN users u ON b.user_id = u.id
WHERE u.n_first_name ILIKE $1 OR u.n_last_name ILIKE $1 OR u.email ILIKE $1`, "%"+searchTerm+"%").Scan(&ubTotal)
} else {
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM user_giftcard_balances").Scan(&ubTotal)
}
for ubRows.Next() { for ubRows.Next() {
var ub UserBalance var ub UserBalance
var rowTotal int
err = ubRows.Scan( err = ubRows.Scan(
&rowTotal,
&ub.UserID, &ub.UserID,
&ub.Name, &ub.Name,
&ub.Email, &ub.Email,
@@ -288,15 +297,21 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
if ubTotal == 0 {
ubTotal = rowTotal
}
resp.UserBalances = append(resp.UserBalances, ub) resp.UserBalances = append(resp.UserBalances, ub)
} }
resp.UBTotal = ubTotal resp.UBTotal = ubTotal
resp.Total = gcTotal resp.Total = gcTotal
var nextCursor *string
if len(resp.GiftCards) > perPage {
resp.GiftCards = resp.GiftCards[:perPage]
last := resp.GiftCards[len(resp.GiftCards)-1]
cursor := last.CreatedAt.Format(time.RFC3339) + "|" + last.ID
nextCursor = &cursor
}
resp.NextCursor = nextCursor
totalPages := (gcTotal + perPage - 1) / perPage totalPages := (gcTotal + perPage - 1) / perPage
if totalPages == 0 { if totalPages == 0 {
totalPages = 1 totalPages = 1
@@ -711,7 +726,7 @@ func RedeemGiftCard(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"status": "success", "status": "success",
"amount_redeemed": amountRemaining, "amount_redeemed": amountRemaining,
}) })
} }
@@ -984,15 +999,13 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"status": "success", "status": "success",
"code": cardID, "code": cardID,
"amount": amountPounds, "amount": amountPounds,
}) })
} }
// --- Helpers --- // --- Helpers ---
type ExpiredBalance struct { type ExpiredBalance struct {
ID string `json:"id"` ID string `json:"id"`
AccountID *string `json:"account_id,omitempty"` AccountID *string `json:"account_id,omitempty"`
File diff suppressed because it is too large Load Diff
+643 -21
View File
@@ -13,6 +13,7 @@ import (
"net/http/httptest" "net/http/httptest"
"os" "os"
"testing" "testing"
"time"
"crussell/db" "crussell/db"
"crussell/internal/square" "crussell/internal/square"
@@ -224,6 +225,17 @@ func TestTerminalPayment_HappyPath(t *testing.T) {
} }
func setupTestData(t *testing.T) (string, string, string) { func setupTestData(t *testing.T) (string, string, string) {
return setupTestDataAtTime(t, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
}
// setupTestDataPast creates a booking with start_time in the past (1 hour ago)
// to prevent payment-split logic from triggering. Used by tests that verify
// payment sequencing or idempotency rather than deposit allocation.
func setupTestDataPast(t *testing.T) (string, string, string) {
return setupTestDataAtTime(t, time.Now().Add(-1*time.Hour))
}
func setupTestDataAtTime(t *testing.T, startTime time.Time) (string, string, string) {
userID, err := fixtures.CreateTestUser(db.DB) userID, err := fixtures.CreateTestUser(db.DB)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -234,7 +246,7 @@ func setupTestData(t *testing.T) (string, string, string) {
t.Fatalf("failed to create test service: %v", err) t.Fatalf("failed to create test service: %v", err)
} }
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, startTime)
if err != nil { if err != nil {
t.Fatalf("failed to create test booking: %v", err) t.Fatalf("failed to create test booking: %v", err)
} }
@@ -742,7 +754,7 @@ func TestTipPayment_NoPriorPayment(t *testing.T) {
func TestIdempotency_SameKeyReturnsExisting(t *testing.T) { func TestIdempotency_SameKeyReturnsExisting(t *testing.T) {
resetTestData(t) resetTestData(t)
userID, bookingID, _ := setupTestData(t) userID, bookingID, _ := setupTestDataPast(t)
userToken := jwt.GenerateUserToken(userID) userToken := jwt.GenerateUserToken(userID)
@@ -803,7 +815,7 @@ func TestIdempotency_SameKeyReturnsExisting(t *testing.T) {
func TestIdempotency_DifferentKeyCreatesNew(t *testing.T) { func TestIdempotency_DifferentKeyCreatesNew(t *testing.T) {
resetTestData(t) resetTestData(t)
userID, bookingID, _ := setupTestData(t) userID, bookingID, _ := setupTestDataPast(t)
userToken := jwt.GenerateUserToken(userID) userToken := jwt.GenerateUserToken(userID)
@@ -832,8 +844,11 @@ func TestIdempotency_DifferentKeyCreatesNew(t *testing.T) {
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken) w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken)
if w2.Code != http.StatusOK { // The second request is blocked because only one "full" payment is
t.Errorf("second request expected status 200, got %d. body: %s", w2.Code, w2.Body.String()) // allowed per booking (the payment-type duplicate guard prevents the
// two-tab double-payment race even when idempotency keys differ).
if w2.Code != http.StatusConflict {
t.Errorf("second request expected status 409, got %d. body: %s", w2.Code, w2.Body.String())
} }
var count int var count int
@@ -841,8 +856,166 @@ func TestIdempotency_DifferentKeyCreatesNew(t *testing.T) {
if err != nil { if err != nil {
t.Errorf("failed to query payments: %v", err) t.Errorf("failed to query payments: %v", err)
} }
if count != 2 { if count != 1 {
t.Errorf("expected 2 payments (different keys), got %d", count) t.Errorf("expected 1 payment (second was blocked), got %d", count)
}
}
// =============================================================================
// Payment-type duplicate guard — serialization lock prevents double payments
// =============================================================================
func TestCreateBookingPayment_DifferentPaymentTypesAllowed(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupPaymentStatusTest(t, "pending_release")
// First: a deposit payment should succeed.
cardToken := "cnon:diff-type-card"
depositReq := CreateBookingPaymentRequest{
Amount: 2000,
PaymentType: "deposit",
NewCardToken: &cardToken,
IdempotencyKey: "diff-type-deposit-" + bookingID,
}
handler := CreateBookingPayment
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", depositReq, userToken)
if w1.Code != http.StatusOK {
t.Fatalf("deposit payment expected 200, got %d. body: %s", w1.Code, w1.Body.String())
}
// Second: a balance payment uses a different payment_type — should also succeed.
balanceReq := CreateBookingPaymentRequest{
Amount: 3000,
PaymentType: "balance",
NewCardToken: &cardToken,
IdempotencyKey: "diff-type-balance-" + bookingID,
}
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", balanceReq, userToken)
if w2.Code != http.StatusOK {
t.Errorf("balance payment expected 200 (different type allowed), got %d. body: %s", w2.Code, w2.Body.String())
}
// Verify at least one payment of each type exists. buildSplitRecords may
// create extra records (e.g. a 'balance' portion alongside 'deposit'), so
// we check DISTINCT types rather than a raw row count.
var distinctTypes []string
rows, err := db.DB.Query(context.Background(),
"SELECT DISTINCT payment_type FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') ORDER BY payment_type",
bookingID)
if err != nil {
t.Fatalf("failed to query payments: %v", err)
}
defer rows.Close()
for rows.Next() {
var pt string
if err := rows.Scan(&pt); err == nil {
distinctTypes = append(distinctTypes, pt)
}
}
if len(distinctTypes) < 2 {
t.Errorf("expected at least 2 distinct payment types, got %d: %v", len(distinctTypes), distinctTypes)
}
}
func TestCreateBookingPayment_DuplicateTypeBlocked(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupPaymentStatusTest(t, "pending_release")
cardToken := "cnon:dup-type-card"
// First 'full' payment succeeds.
req1 := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "dup-type-first-" + bookingID,
}
handler := CreateBookingPayment
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken)
if w1.Code != http.StatusOK {
t.Fatalf("first payment expected 200, got %d. body: %s", w1.Code, w1.Body.String())
}
// Second 'full' payment with a different idempotency key should be blocked
// by the payment-type duplicate guard.
req2 := CreateBookingPaymentRequest{
Amount: 2000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "dup-type-second-" + bookingID,
}
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken)
if w2.Code != http.StatusConflict {
t.Errorf("duplicate 'full' payment expected 409, got %d. body: %s", w2.Code, w2.Body.String())
}
// Verify only one real payment was created. buildSplitRecords converts the
// first 'full' payment into 'deposit' + 'balance', so we count deposit records
// rather than 'full' — the exact guard above confirmed the 409 rejection.
var depositCount int
err := db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'deposit' AND payment_method NOT IN ('discount', 'on_the_house')",
bookingID).Scan(&depositCount)
if err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if depositCount != 1 {
t.Errorf("expected 1 deposit record (split from first 'full' payment), got %d", depositCount)
}
}
func TestCreateBookingPayment_MultiplePartialAllowed(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupPaymentStatusTest(t, "confirmed")
cardToken := "cnon:partial-card"
// First partial payment.
req1 := CreateBookingPaymentRequest{
Amount: 1000,
PaymentType: "partial",
NewCardToken: &cardToken,
IdempotencyKey: "partial-first-" + bookingID,
}
handler := CreateBookingPayment
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken)
if w1.Code != http.StatusOK {
t.Fatalf("first partial expected 200, got %d. body: %s", w1.Code, w1.Body.String())
}
// Second partial payment (different key, same type) — allowed because
// the duplicate guard explicitly exempts 'partial'.
req2 := CreateBookingPaymentRequest{
Amount: 1500,
PaymentType: "partial",
NewCardToken: &cardToken,
IdempotencyKey: "partial-second-" + bookingID,
}
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken)
if w2.Code != http.StatusOK {
t.Errorf("second partial expected 200, got %d. body: %s", w2.Code, w2.Body.String())
}
// Count all real payments (buildSplitRecords converts partials to deposit
// when within the 50% deposit cap). Both should have been created.
var total int
err := db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house')",
bookingID).Scan(&total)
if err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if total != 2 {
t.Errorf("expected 2 payments (both created), got %d", total)
} }
} }
@@ -864,6 +1037,17 @@ func TestSquareWebhook_DevMode_NoSignature(t *testing.T) {
// ============================================================ // ============================================================
func setupDepositBooking(t *testing.T) (string, string) { func setupDepositBooking(t *testing.T) (string, string) {
return setupDepositBookingAtTime(t, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
}
// setupDepositBookingPast creates a confirmed booking with start_time in the past
// (1 hour ago). This prevents the payment-split logic from triggering, which is
// useful for tests that verify payment sequencing rather than deposit splitting.
func setupDepositBookingPast(t *testing.T) (string, string) {
return setupDepositBookingAtTime(t, time.Now().Add(-1*time.Hour))
}
func setupDepositBookingAtTime(t *testing.T, startTime time.Time) (string, string) {
userID, err := fixtures.CreateTestUser(db.DB) userID, err := fixtures.CreateTestUser(db.DB)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -874,7 +1058,7 @@ func setupDepositBooking(t *testing.T) (string, string) {
t.Fatalf("failed to create test service: %v", err) t.Fatalf("failed to create test service: %v", err)
} }
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, startTime)
if err != nil { if err != nil {
t.Fatalf("failed to create test booking: %v", err) t.Fatalf("failed to create test booking: %v", err)
} }
@@ -1039,6 +1223,443 @@ func TestBookingPayment_BalancePayment(t *testing.T) {
} }
} }
// ---------------------------------------------------------------------------
// Payment-split tests — verify that a single Square charge is recorded as
// multiple payment rows when paid before the booking start time, and that
// both records share the same square_payment_id.
// ---------------------------------------------------------------------------
func TestBookingPayment_FullPayment_SplitsIntoDepositAndBalance(t *testing.T) {
// A full payment of £50 on a £50 booking (future-dated) should be split:
// record 1: payment_type='deposit', amount=25.00
// record 2: payment_type='balance', amount=25.00
resetTestData(t)
userID, bookingID := setupDepositBooking(t)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:split-full-card"
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "split-full-test-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Should be exactly 2 payment records.
rows, err := db.DB.Query(context.Background(),
`SELECT payment_type, amount, square_payment_id
FROM payments WHERE booking_id = $1 ORDER BY amount DESC`, bookingID)
if err != nil {
t.Fatalf("failed to query payments: %v", err)
}
defer rows.Close()
var records []struct {
ptype string
amount float64
squarePaymentID *string
}
for rows.Next() {
var r struct {
ptype string
amount float64
squarePaymentID *string
}
if err := rows.Scan(&r.ptype, &r.amount, &r.squarePaymentID); err != nil {
t.Fatalf("failed to scan row: %v", err)
}
records = append(records, r)
}
if len(records) != 2 {
t.Fatalf("expected 2 split records, got %d", len(records))
}
// First record should be the deposit portion (larger or equal — deposit is 25, balance is 25).
if records[0].ptype != "deposit" {
t.Errorf("expected first record to be 'deposit', got %q", records[0].ptype)
}
// Second record should be balance.
if records[1].ptype != "balance" {
t.Errorf("expected second record to be 'balance', got %q", records[1].ptype)
}
// Both records must share the same square_payment_id.
if records[0].squarePaymentID == nil || records[1].squarePaymentID == nil {
t.Error("both records should have a square_payment_id")
} else if *records[0].squarePaymentID != *records[1].squarePaymentID {
t.Errorf("expected same square_payment_id, got %q and %q",
*records[0].squarePaymentID, *records[1].squarePaymentID)
}
}
func TestBookingPayment_FullPayment_PastBooking_DoesNotSplit(t *testing.T) {
// A full payment on a PAST booking should NOT split (single record).
resetTestData(t)
userID, bookingID := setupDepositBookingPast(t)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:nosplit-card"
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "nosplit-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var count int
err := db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
if err != nil {
t.Fatalf("failed to query payments: %v", err)
}
if count != 1 {
t.Errorf("expected 1 payment (no-split), got %d", count)
}
}
func TestBookingPayment_TransactionAtomicity_SplitRollsBackOnError(t *testing.T) {
// Verify that when the split-record insert fails, the entire group rolls
// back atomically. We simulate a failure by causing the second INSERT to
// violate a NOT NULL constraint (passing an invalid record).
resetTestData(t)
_, bookingID := setupDepositBooking(t)
// Use a nil idempotency key on the split record — this works fine for both.
// Instead we rely on the fact that the handler wraps both inserts in a
// single transaction: if either fails, neither survives.
//
// Because we can't easily inject a DB error through the handler, we verify
// the architecture at the service level instead:
ctx := context.Background()
tx, err := db.DB.Begin(ctx)
if err != nil {
t.Fatalf("failed to begin tx: %v", err)
}
defer tx.Rollback(ctx)
svc := NewPaymentService()
now := time.Now()
// First record — valid.
pid1, err := svc.CreatePaymentRecordTx(ctx, tx, PaymentRecord{
BookingID: bookingID,
PaymentType: "deposit",
PaymentMethod: "cash",
Status: "completed",
Amount: 25.00,
CreatedAt: now,
UpdatedAt: now,
}, nil)
if err != nil {
t.Fatalf("failed to create first payment record: %v", err)
}
if pid1 == "" {
t.Fatal("expected non-empty payment id")
}
// Second record — also valid.
pid2, err := svc.CreatePaymentRecordTx(ctx, tx, PaymentRecord{
BookingID: bookingID,
PaymentType: "balance",
PaymentMethod: "cash",
Status: "completed",
Amount: 25.00,
CreatedAt: now,
UpdatedAt: now,
}, nil)
if err != nil {
t.Fatalf("failed to create second payment record: %v", err)
}
if pid2 == "" {
t.Fatal("expected non-empty payment id")
}
if err := tx.Commit(ctx); err != nil {
t.Fatalf("failed to commit tx: %v", err)
}
// Both records should exist.
var count int
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1 OR id = $2", pid1, pid2).Scan(&count)
if count != 2 {
t.Errorf("expected 2 committed records, got %d", count)
}
// Now test rollback: start a new tx, insert, then rollback.
tx2, err := db.DB.Begin(ctx)
if err != nil {
t.Fatalf("failed to begin tx2: %v", err)
}
pid3, err := svc.CreatePaymentRecordTx(ctx, tx2, PaymentRecord{
BookingID: bookingID,
PaymentType: "deposit",
PaymentMethod: "cash",
Status: "completed",
Amount: 10.00,
CreatedAt: now,
UpdatedAt: now,
}, nil)
if err != nil {
t.Fatalf("failed to create rolled-back record: %v", err)
}
tx2.Rollback(ctx)
// Rolled-back record should NOT exist.
var rollbackCount int
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", pid3).Scan(&rollbackCount)
if rollbackCount != 0 {
t.Errorf("expected 0 records after rollback, got %d", rollbackCount)
}
}
// ---------------------------------------------------------------------------
// nonDepositPaymentType unit tests — pure function, no DB needed.
// ---------------------------------------------------------------------------
func TestNonDepositPaymentType_FirstPaymentFull(t *testing.T) {
// First-ever payment, paying full amount → "full"
result := nonDepositPaymentType("full", 100, 100, 100)
if result != "full" {
t.Errorf("expected 'full', got %q", result)
}
}
func TestNonDepositPaymentType_BalanceWhenPriorExists(t *testing.T) {
// Total paid after this = 100, portion = 50, prior = 50 → "balance"
result := nonDepositPaymentType("balance", 100, 50, 100)
if result != "balance" {
t.Errorf("expected 'balance', got %q", result)
}
}
func TestNonDepositPaymentType_PartialWhenUnderTotal(t *testing.T) {
// Paying 30 on a 100 total → "partial"
result := nonDepositPaymentType("partial", 30, 30, 100)
if result != "partial" {
t.Errorf("expected 'partial', got %q", result)
}
// Same result when request type is "full" but amount doesn't cover total
result = nonDepositPaymentType("full", 80, 80, 100)
if result != "partial" {
t.Errorf("expected 'partial' when full doesn't cover total, got %q", result)
}
}
func TestNonDepositPaymentType_FullWhenFirstPaymentFullyCovers(t *testing.T) {
// First payment ever, exactly covers total → "full"
result := nonDepositPaymentType("full", 100, 100, 100)
if result != "full" {
t.Errorf("expected 'full', got %q", result)
}
}
func TestNonDepositPaymentType_DepositReqTypeBecomesPartial(t *testing.T) {
// Request type is "deposit" but amount doesn't fully cover → "partial"
result := nonDepositPaymentType("deposit", 30, 30, 100)
if result != "partial" {
t.Errorf("expected 'partial' for deposit type under total, got %q", result)
}
}
// ---------------------------------------------------------------------------
// buildSplitRecords unit tests — pure function, no DB needed.
// ---------------------------------------------------------------------------
func makeTestRecord(bookingID, ptype string, amount float64) PaymentRecord {
now := time.Now()
key := "test-key"
return PaymentRecord{
BookingID: bookingID,
PaymentType: ptype,
PaymentMethod: "online_square",
Status: "completed",
Amount: amount,
SquarePaymentID: strPtr("sq_test"),
IdempotencyKey: &key,
Fees: 1.50,
CreatedAt: now,
UpdatedAt: now,
}
}
func strPtr(s string) *string { return &s }
func TestBuildSplitRecords_FutureBooking_FullPayment_Splits(t *testing.T) {
// £50 payment on a £50 future booking → splits into deposit £25 + balance £25
record := makeTestRecord("b1", "full", 50)
info := &BookingPaymentInfo{
StartTime: time.Now().Add(48 * time.Hour),
TotalAmount: 50,
TotalPaid: 0,
}
records := buildSplitRecords(record, "full", info, 50)
if len(records) != 2 {
t.Fatalf("expected 2 records, got %d", len(records))
}
if records[0].PaymentType != "deposit" {
t.Errorf("expected first record 'deposit', got %q", records[0].PaymentType)
}
if records[0].Amount != 25 {
t.Errorf("expected first record amount 25, got %.2f", records[0].Amount)
}
if records[1].PaymentType != "balance" {
t.Errorf("expected second record 'balance', got %q", records[1].PaymentType)
}
if records[1].Amount != 25 {
t.Errorf("expected second record amount 25, got %.2f", records[1].Amount)
}
// Both share the same SquarePaymentID.
if *records[0].SquarePaymentID != *records[1].SquarePaymentID {
t.Error("split records must share square_payment_id")
}
// Split record has separate idempotency key.
if *records[1].IdempotencyKey != *records[0].IdempotencyKey+"-split-1" {
t.Errorf("split key should be derived, got %q", *records[1].IdempotencyKey)
}
// Split record has zero fees (all on primary).
if records[1].Fees != 0 {
t.Errorf("expected split fees=0, got %.2f", records[1].Fees)
}
}
func TestBuildSplitRecords_PastBooking_NoSplit(t *testing.T) {
// Same amount on a PAST booking → single record
record := makeTestRecord("b2", "full", 50)
info := &BookingPaymentInfo{
StartTime: time.Now().Add(-2 * time.Hour),
TotalAmount: 50,
TotalPaid: 0,
}
records := buildSplitRecords(record, "full", info, 50)
if len(records) != 1 {
t.Fatalf("expected 1 record (no split), got %d", len(records))
}
if records[0].PaymentType != "full" {
t.Errorf("expected 'full', got %q", records[0].PaymentType)
}
}
func TestBuildSplitRecords_DepositWithinCap_NoSplit(t *testing.T) {
// £20 deposit on a £50 total (40% < 50% cap) → single deposit record
record := makeTestRecord("b3", "deposit", 20)
info := &BookingPaymentInfo{
StartTime: time.Now().Add(48 * time.Hour),
TotalAmount: 50,
TotalPaid: 0,
}
records := buildSplitRecords(record, "deposit", info, 20)
if len(records) != 1 {
t.Fatalf("expected 1 record (within cap), got %d", len(records))
}
if records[0].PaymentType != "deposit" {
t.Errorf("expected 'deposit', got %q", records[0].PaymentType)
}
}
func TestBuildSplitRecords_PaymentLessThanDepositMax_NoSplit(t *testing.T) {
// £25 on a £100 total (25% < 50% cap) → single deposit record
record := makeTestRecord("b4", "deposit", 25)
info := &BookingPaymentInfo{
StartTime: time.Now().Add(48 * time.Hour),
TotalAmount: 100,
TotalPaid: 0,
}
records := buildSplitRecords(record, "deposit", info, 25)
if len(records) != 1 {
t.Fatalf("expected 1 record (under 50%%), got %d", len(records))
}
}
// ---------------------------------------------------------------------------
// Handler-level atomicity — verify the full handler succeeds with split.
// ---------------------------------------------------------------------------
func TestBookingPayment_HandlerAtomicity_SplitSucceeds(t *testing.T) {
resetTestData(t)
userID, bookingID := setupDepositBooking(t)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:atomic-card"
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "atomic-test-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp PaymentResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.ID == "" {
t.Fatal("expected non-empty payment ID")
}
if resp.Amount != 5000 {
t.Errorf("expected amount 5000, got %d", resp.Amount)
}
if resp.PaymentType != "full" {
t.Errorf("expected payment type 'full' in response, got %q", resp.PaymentType)
}
// Verify both split records exist and the total paid is correct.
var recordCount int
db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'", bookingID).Scan(&recordCount)
if recordCount != 2 {
t.Errorf("expected 2 completed payment records from split, got %d", recordCount)
}
var totalPaid float64
db.DB.QueryRow(context.Background(),
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed'", bookingID).Scan(&totalPaid)
if totalPaid != 50.00 {
t.Errorf("expected total paid £50.00, got £%.2f", totalPaid)
}
// Deposit threshold should have been met — verify booking promoted from pending_release.
var status string
db.DB.QueryRow(context.Background(),
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if status == "pending_release" {
t.Error("expected booking to be promoted from pending_release after payment meets 20% threshold")
}
}
func TestBookingPayment_ZeroAmountRejected(t *testing.T) { func TestBookingPayment_ZeroAmountRejected(t *testing.T) {
resetTestData(t) resetTestData(t)
@@ -1174,7 +1795,8 @@ func TestBookingPayment_DepositFollowedByBalance(t *testing.T) {
func TestBookingPayment_PartialFollowedByBalance(t *testing.T) { func TestBookingPayment_PartialFollowedByBalance(t *testing.T) {
resetTestData(t) resetTestData(t)
userID, bookingID := setupDepositBooking(t) // Past booking to avoid payment-split; we're testing sequence not deposit allocation.
userID, bookingID := setupDepositBookingPast(t)
userToken := jwt.GenerateUserToken(userID) userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:partial-balance-card" cardToken := "cnon:partial-balance-card"
@@ -1348,10 +1970,10 @@ func TestDeletePaymentMethod_WrongOwnerRejected(t *testing.T) {
func TestValidatePartialAmount(t *testing.T) { func TestValidatePartialAmount(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
amountCents int64 amountCents int64
remainingCents int64 remainingCents int64
expectErr bool expectErr bool
}{ }{
{"valid partial", 500, 1000, false}, {"valid partial", 500, 1000, false},
{"exact remaining", 1000, 1000, false}, {"exact remaining", 1000, 1000, false},
@@ -1402,11 +2024,11 @@ func TestGetBookingRemainingBalanceCents(t *testing.T) {
} }
_, err = service.CreatePaymentRecord(context.Background(), PaymentRecord{ _, err = service.CreatePaymentRecord(context.Background(), PaymentRecord{
BookingID: bookingID, BookingID: bookingID,
PaymentType: "partial", PaymentType: "partial",
PaymentMethod: "cash", PaymentMethod: "cash",
Status: "completed", Status: "completed",
Amount: 20.00, Amount: 20.00,
}, nil) }, nil)
if err != nil { if err != nil {
t.Fatalf("failed to create payment: %v", err) t.Fatalf("failed to create payment: %v", err)
@@ -1421,11 +2043,11 @@ func TestGetBookingRemainingBalanceCents(t *testing.T) {
} }
_, err = service.CreatePaymentRecord(context.Background(), PaymentRecord{ _, err = service.CreatePaymentRecord(context.Background(), PaymentRecord{
BookingID: bookingID, BookingID: bookingID,
PaymentType: "balance", PaymentType: "balance",
PaymentMethod: "cash", PaymentMethod: "cash",
Status: "completed", Status: "completed",
Amount: float64(afterPartial) / 100.0, Amount: float64(afterPartial) / 100.0,
}, nil) }, nil)
if err != nil { if err != nil {
t.Fatalf("failed to create payment: %v", err) t.Fatalf("failed to create payment: %v", err)
@@ -1632,4 +2254,4 @@ func TestCreatePaymentMethod_SecondCardNotDefault(t *testing.T) {
if card.IsDefault { if card.IsDefault {
t.Error("expected second card to NOT be default") t.Error("expected second card to NOT be default")
} }
} }
+99 -49
View File
@@ -14,14 +14,14 @@ import (
) )
type SavedCard struct { type SavedCard struct {
ID string `json:"id"` ID string `json:"id"`
SquareCardID string `json:"square_card_id"` SquareCardID string `json:"square_card_id"`
Brand string `json:"brand"` Brand string `json:"brand"`
Last4 string `json:"last_4"` Last4 string `json:"last_4"`
ExpMonth int `json:"exp_month"` ExpMonth int `json:"exp_month"`
ExpYear int `json:"exp_year"` ExpYear int `json:"exp_year"`
Fingerprint string `json:"fingerprint"` Fingerprint string `json:"fingerprint"`
IsDefault bool `json:"is_default"` IsDefault bool `json:"is_default"`
} }
type PaymentService struct{} type PaymentService struct{}
@@ -31,39 +31,39 @@ func NewPaymentService() *PaymentService {
} }
type PaymentRecord struct { type PaymentRecord struct {
ID string ID string
BookingID string BookingID string
PaymentType string PaymentType string
PaymentMethod string PaymentMethod string
VendorCode *string VendorCode *string
InvoiceNumber *int InvoiceNumber *int
Status string Status string
Amount float64 Amount float64
CardLast4 string CardLast4 string
IsVATApplicable bool IsVATApplicable bool
VATRate *float64 VATRate *float64
VATAmount *float64 VATAmount *float64
NetAmount *float64 NetAmount *float64
UserSavedCardID *string UserSavedCardID *string
SquarePaymentID *string SquarePaymentID *string
IdempotencyKey *string IdempotencyKey *string
Fees float64 Fees float64
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
CreatedBy *string CreatedBy *string
GiftCardID *string GiftCardID *string
} }
type RefundRecord struct { type RefundRecord struct {
ID string ID string
PaymentID string PaymentID string
BookingID string BookingID string
Amount float64 Amount float64
SquareRefundID *string SquareRefundID *string
Status string Status string
Reason string Reason string
CreatedBy *string CreatedBy *string
CreatedAt time.Time CreatedAt time.Time
} }
type PaymentSummary struct { type PaymentSummary struct {
@@ -77,19 +77,39 @@ type PaymentSummary struct {
func (s *PaymentService) CalculateFees(amount int64, method string) float64 { func (s *PaymentService) CalculateFees(amount int64, method string) float64 {
if method == "online" { if method == "online" {
return float64((amount * 14 / 1000) + 25) / 100.0 return float64((amount*14/1000)+25) / 100.0
} }
return float64(amount * 175 / 10000) / 100.0 return float64(amount*175/10000) / 100.0
} }
func (s *PaymentService) CreatePaymentRecord(ctx context.Context, record PaymentRecord, giftCardID *string) (string, error) { func (s *PaymentService) CreatePaymentRecord(ctx context.Context, record PaymentRecord, giftCardID *string) (string, error) {
return s.insertPaymentRecord(ctx, record, giftCardID, db.DB)
}
// CreatePaymentRecordTx is identical to CreatePaymentRecord but accepts a
// pgx.Tx so the insert is part of an existing database transaction. This
// is used by CreateBookingPayment when inserting multiple split records
// from a single Square charge — wrapping both inserts in a transaction
// ensures atomicity (both succeed or both roll back).
func (s *PaymentService) CreatePaymentRecordTx(ctx context.Context, tx pgx.Tx, record PaymentRecord, giftCardID *string) (string, error) {
return s.insertPaymentRecord(ctx, record, giftCardID, tx)
}
// insertPaymentRecord holds the common INSERT logic. The querier parameter
// accepts either *pgxpool.Pool or pgx.Tx so callers can choose transactional
// or non-transactional insertion.
type querier interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
func (s *PaymentService) insertPaymentRecord(ctx context.Context, record PaymentRecord, giftCardID *string, q querier) (string, error) {
var bookingID *string var bookingID *string
if record.BookingID != "" { if record.BookingID != "" {
bookingID = &record.BookingID bookingID = &record.BookingID
} }
var id string var id string
err := db.DB.QueryRow(ctx, ` err := q.QueryRow(ctx, `
INSERT INTO payments ( INSERT INTO payments (
booking_id, payment_type, payment_method, vendor_code, invoice_number, booking_id, payment_type, payment_method, vendor_code, invoice_number,
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount, status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
@@ -326,14 +346,6 @@ func (s *PaymentService) GetAlreadyRefundedAmount(ctx context.Context, paymentID
return int64(amount * 100), nil return int64(amount * 100), nil
} }
func (s *PaymentService) UpdateBookingDepositPaid(ctx context.Context, bookingID string, depositPaid bool) error {
_, err := db.DB.Exec(ctx, `
UPDATE bookings SET deposit_paid = $1 WHERE id = $2
`, depositPaid, bookingID)
return err
}
func (s *PaymentService) HasCompletedPayment(ctx context.Context, bookingID string) (bool, error) { func (s *PaymentService) HasCompletedPayment(ctx context.Context, bookingID string) (bool, error) {
var count int var count int
err := db.DB.QueryRow(ctx, ` err := db.DB.QueryRow(ctx, `
@@ -355,6 +367,44 @@ func (s *PaymentService) GetBookingStatus(ctx context.Context, bookingID string)
return status, nil return status, nil
} }
// BookingPaymentInfo holds booking-level data needed for payment split decisions.
type BookingPaymentInfo struct {
StartTime time.Time
TotalAmount float64
TotalPaid float64
Status string
}
// GetBookingPaymentInfo fetches the booking start time, total service amount, and
// total completed payments for a booking.
func (s *PaymentService) GetBookingPaymentInfo(ctx context.Context, bookingID string) (*BookingPaymentInfo, error) {
var info BookingPaymentInfo
err := db.DB.QueryRow(ctx, `
SELECT b.start_time, b.status,
COALESCE(bt.total_amount, 0),
COALESCE(pt.total_paid, 0)
FROM bookings b
LEFT JOIN (
SELECT booking_id, COALESCE(SUM(price_val), 0) AS total_amount FROM (
SELECT bs.booking_id, COALESCE(bs.override_price, s.price) AS price_val
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1
UNION ALL
SELECT bcs.booking_id, COALESCE(bcs.override_price, cs.price)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = $1
) sub GROUP BY booking_id
) bt ON b.id = bt.booking_id
LEFT JOIN (
SELECT booking_id, SUM(amount) AS total_paid
FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') GROUP BY booking_id
) pt ON b.id = pt.booking_id
WHERE b.id = $1
`, bookingID).Scan(&info.StartTime, &info.Status, &info.TotalAmount, &info.TotalPaid)
if err != nil {
return nil, err
}
return &info, nil
}
func (s *PaymentService) GetBookingUserID(ctx context.Context, bookingID string) (string, error) { func (s *PaymentService) GetBookingUserID(ctx context.Context, bookingID string) (string, error) {
var userID string var userID string
err := db.DB.QueryRow(ctx, `SELECT user_id FROM bookings WHERE id = $1`, bookingID).Scan(&userID) err := db.DB.QueryRow(ctx, `SELECT user_id FROM bookings WHERE id = $1`, bookingID).Scan(&userID)
@@ -515,4 +565,4 @@ func (s *PaymentService) GetCardByID(ctx context.Context, cardID, userID string)
return &c, nil return &c, nil
} }
var SquareClient square.SquareClient var SquareClient square.SquareClient
+12 -4
View File
@@ -18,11 +18,11 @@ import (
) )
type TillSaleRequest struct { type TillSaleRequest struct {
ItemType string `json:"item_type"` ItemType string `json:"item_type" validate:"required"`
Action string `json:"action"` Action string `json:"action" validate:"required"`
Amount float64 `json:"amount"` Amount float64 `json:"amount" validate:"required,gt=0"`
GiftCardID *string `json:"gift_card_id,omitempty"` GiftCardID *string `json:"gift_card_id,omitempty"`
PaymentMethod string `json:"payment_method"` PaymentMethod string `json:"payment_method" validate:"required"`
UserSavedCardID *string `json:"user_saved_card_id,omitempty"` UserSavedCardID *string `json:"user_saved_card_id,omitempty"`
UserID *string `json:"user_id,omitempty"` UserID *string `json:"user_id,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"` IdempotencyKey string `json:"idempotency_key,omitempty"`
@@ -53,6 +53,14 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// M8
// L5
if req.ItemType != "gift_card" { if req.ItemType != "gift_card" {
http.Error(w, "Unsupported item type", http.StatusBadRequest) http.Error(w, "Unsupported item type", http.StatusBadRequest)
return return
+1 -1
View File
@@ -58,4 +58,4 @@ func ValidateCardInfo(cardID, newCardToken *string) error {
return errors.New("either card_id or new_card_token is required") return errors.New("either card_id or new_card_token is required")
} }
return nil return nil
} }