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:
@@ -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
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user