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 ---
|
||||
|
||||
type GiftCard struct {
|
||||
ID string `json:"id"`
|
||||
TotalFundsAdded float64 `json:"total_funds_added"`
|
||||
AmountRemaining float64 `json:"amount_remaining"`
|
||||
CreatedBy *string `json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
RedeemedAt *time.Time `json:"redeemed_at,omitempty"`
|
||||
RedeemedBy *string `json:"redeemed_by,omitempty"`
|
||||
IsInventory bool `json:"is_inventory"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
ID string `json:"id"`
|
||||
TotalFundsAdded float64 `json:"total_funds_added"`
|
||||
AmountRemaining float64 `json:"amount_remaining"`
|
||||
CreatedBy *string `json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
RedeemedAt *time.Time `json:"redeemed_at,omitempty"`
|
||||
RedeemedBy *string `json:"redeemed_by,omitempty"`
|
||||
IsInventory bool `json:"is_inventory"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
}
|
||||
|
||||
type UserBalance struct {
|
||||
@@ -52,6 +52,7 @@ type GiftCardListResponse struct {
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"perPage"`
|
||||
TotalPages int `json:"totalPages"`
|
||||
NextCursor *string `json:"next_cursor,omitempty"`
|
||||
}
|
||||
|
||||
type CreateGiftCardRequest struct {
|
||||
@@ -93,14 +94,8 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
|
||||
searchTerm := query.Get("q")
|
||||
|
||||
// Pagination parameters
|
||||
page := 1
|
||||
perPage := 10
|
||||
|
||||
if pageStr := query.Get("page"); pageStr != "" {
|
||||
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
cursorStr := query.Get("cursor")
|
||||
|
||||
if perPageStr := query.Get("per_page"); perPageStr != "" {
|
||||
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
|
||||
resp.GiftCards = []GiftCard{}
|
||||
@@ -158,33 +159,41 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var gcTotal int
|
||||
var gcCountArgs []interface{}
|
||||
var gcListArgs []interface{}
|
||||
|
||||
gcCountQuery := fmt.Sprintf(`SELECT COUNT(*) FROM gift_cards %s`, whereSQL)
|
||||
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
|
||||
%s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $%%d OFFSET $%%d
|
||||
`, whereSQL)
|
||||
|
||||
if searchTerm != "" {
|
||||
searchPattern := "%" + searchTerm + "%"
|
||||
gcCountArgs = []interface{}{searchPattern}
|
||||
gcListArgs = []interface{}{searchPattern, perPage, offset}
|
||||
gcListQuery = fmt.Sprintf(gcListQuery, 2, 3)
|
||||
} else {
|
||||
gcListArgs = []interface{}{perPage, offset}
|
||||
gcListQuery = fmt.Sprintf(gcListQuery, 1, 2)
|
||||
}
|
||||
gcListArgs = []interface{}{searchPattern}
|
||||
|
||||
err = db.DB.QueryRow(ctx, gcCountQuery, gcCountArgs...).Scan(&gcTotal)
|
||||
if err != nil {
|
||||
log.Printf("Failed to count gift cards: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
if cursorStr != "" {
|
||||
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid cursor: "+err.Error(), http.StatusBadRequest)
|
||||
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...)
|
||||
@@ -195,21 +204,27 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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() {
|
||||
var gc GiftCard
|
||||
var createdBy, redeemedBy sql.NullString
|
||||
var redeemedAt, lastUsedAt sql.NullTime
|
||||
|
||||
err = gcRows.Scan(
|
||||
&gc.ID,
|
||||
&gc.TotalFundsAdded,
|
||||
&gc.AmountRemaining,
|
||||
&createdBy,
|
||||
&gc.CreatedAt,
|
||||
&redeemedAt,
|
||||
&redeemedBy,
|
||||
&gc.IsInventory,
|
||||
&lastUsedAt,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to scan gift card: %v", err)
|
||||
@@ -217,19 +232,6 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -243,7 +245,7 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
|
||||
searchPattern := "%" + searchTerm + "%"
|
||||
|
||||
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
|
||||
FROM user_giftcard_balances b
|
||||
JOIN users u ON b.user_id = u.id
|
||||
@@ -255,7 +257,7 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
|
||||
ubListArgs = []interface{}{searchPattern}
|
||||
} else {
|
||||
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
|
||||
FROM user_giftcard_balances b
|
||||
JOIN users u ON b.user_id = u.id
|
||||
@@ -272,11 +274,18 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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() {
|
||||
var ub UserBalance
|
||||
var rowTotal int
|
||||
err = ubRows.Scan(
|
||||
&rowTotal,
|
||||
&ub.UserID,
|
||||
&ub.Name,
|
||||
&ub.Email,
|
||||
@@ -288,15 +297,21 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if ubTotal == 0 {
|
||||
ubTotal = rowTotal
|
||||
}
|
||||
resp.UserBalances = append(resp.UserBalances, ub)
|
||||
}
|
||||
|
||||
resp.UBTotal = ubTotal
|
||||
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
|
||||
if totalPages == 0 {
|
||||
totalPages = 1
|
||||
@@ -711,7 +726,7 @@ func RedeemGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "success",
|
||||
"status": "success",
|
||||
"amount_redeemed": amountRemaining,
|
||||
})
|
||||
}
|
||||
@@ -984,15 +999,13 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "success",
|
||||
"code": cardID,
|
||||
"code": cardID,
|
||||
"amount": amountPounds,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
|
||||
|
||||
type ExpiredBalance struct {
|
||||
ID string `json:"id"`
|
||||
AccountID *string `json:"account_id,omitempty"`
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
@@ -224,6 +225,17 @@ func TestTerminalPayment_HappyPath(t *testing.T) {
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, startTime)
|
||||
if err != nil {
|
||||
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) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t)
|
||||
userID, bookingID, _ := setupTestDataPast(t)
|
||||
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
@@ -803,7 +815,7 @@ func TestIdempotency_SameKeyReturnsExisting(t *testing.T) {
|
||||
func TestIdempotency_DifferentKeyCreatesNew(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t)
|
||||
userID, bookingID, _ := setupTestDataPast(t)
|
||||
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
@@ -832,8 +844,11 @@ func TestIdempotency_DifferentKeyCreatesNew(t *testing.T) {
|
||||
|
||||
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Errorf("second request expected status 200, got %d. body: %s", w2.Code, w2.Body.String())
|
||||
// The second request is blocked because only one "full" payment is
|
||||
// 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
|
||||
@@ -841,8 +856,166 @@ func TestIdempotency_DifferentKeyCreatesNew(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Errorf("failed to query payments: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 payments (different keys), got %d", count)
|
||||
if count != 1 {
|
||||
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) {
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, startTime)
|
||||
if err != nil {
|
||||
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) {
|
||||
resetTestData(t)
|
||||
|
||||
@@ -1174,7 +1795,8 @@ func TestBookingPayment_DepositFollowedByBalance(t *testing.T) {
|
||||
func TestBookingPayment_PartialFollowedByBalance(t *testing.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)
|
||||
|
||||
cardToken := "cnon:partial-balance-card"
|
||||
@@ -1348,10 +1970,10 @@ func TestDeletePaymentMethod_WrongOwnerRejected(t *testing.T) {
|
||||
|
||||
func TestValidatePartialAmount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
amountCents int64
|
||||
name string
|
||||
amountCents int64
|
||||
remainingCents int64
|
||||
expectErr bool
|
||||
expectErr bool
|
||||
}{
|
||||
{"valid partial", 500, 1000, false},
|
||||
{"exact remaining", 1000, 1000, false},
|
||||
@@ -1402,11 +2024,11 @@ func TestGetBookingRemainingBalanceCents(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err = service.CreatePaymentRecord(context.Background(), PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
PaymentType: "partial",
|
||||
BookingID: bookingID,
|
||||
PaymentType: "partial",
|
||||
PaymentMethod: "cash",
|
||||
Status: "completed",
|
||||
Amount: 20.00,
|
||||
Status: "completed",
|
||||
Amount: 20.00,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
@@ -1421,11 +2043,11 @@ func TestGetBookingRemainingBalanceCents(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err = service.CreatePaymentRecord(context.Background(), PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
PaymentType: "balance",
|
||||
BookingID: bookingID,
|
||||
PaymentType: "balance",
|
||||
PaymentMethod: "cash",
|
||||
Status: "completed",
|
||||
Amount: float64(afterPartial) / 100.0,
|
||||
Status: "completed",
|
||||
Amount: float64(afterPartial) / 100.0,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
@@ -1632,4 +2254,4 @@ func TestCreatePaymentMethod_SecondCardNotDefault(t *testing.T) {
|
||||
if card.IsDefault {
|
||||
t.Error("expected second card to NOT be default")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,14 +14,14 @@ import (
|
||||
)
|
||||
|
||||
type SavedCard struct {
|
||||
ID string `json:"id"`
|
||||
ID string `json:"id"`
|
||||
SquareCardID string `json:"square_card_id"`
|
||||
Brand string `json:"brand"`
|
||||
Last4 string `json:"last_4"`
|
||||
ExpMonth int `json:"exp_month"`
|
||||
ExpYear int `json:"exp_year"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
Brand string `json:"brand"`
|
||||
Last4 string `json:"last_4"`
|
||||
ExpMonth int `json:"exp_month"`
|
||||
ExpYear int `json:"exp_year"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
type PaymentService struct{}
|
||||
@@ -31,39 +31,39 @@ func NewPaymentService() *PaymentService {
|
||||
}
|
||||
|
||||
type PaymentRecord struct {
|
||||
ID string
|
||||
BookingID string
|
||||
PaymentType string
|
||||
PaymentMethod string
|
||||
VendorCode *string
|
||||
InvoiceNumber *int
|
||||
Status string
|
||||
Amount float64
|
||||
CardLast4 string
|
||||
IsVATApplicable bool
|
||||
VATRate *float64
|
||||
VATAmount *float64
|
||||
NetAmount *float64
|
||||
UserSavedCardID *string
|
||||
SquarePaymentID *string
|
||||
IdempotencyKey *string
|
||||
Fees float64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CreatedBy *string
|
||||
GiftCardID *string
|
||||
ID string
|
||||
BookingID string
|
||||
PaymentType string
|
||||
PaymentMethod string
|
||||
VendorCode *string
|
||||
InvoiceNumber *int
|
||||
Status string
|
||||
Amount float64
|
||||
CardLast4 string
|
||||
IsVATApplicable bool
|
||||
VATRate *float64
|
||||
VATAmount *float64
|
||||
NetAmount *float64
|
||||
UserSavedCardID *string
|
||||
SquarePaymentID *string
|
||||
IdempotencyKey *string
|
||||
Fees float64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CreatedBy *string
|
||||
GiftCardID *string
|
||||
}
|
||||
|
||||
type RefundRecord struct {
|
||||
ID string
|
||||
PaymentID string
|
||||
BookingID string
|
||||
Amount float64
|
||||
SquareRefundID *string
|
||||
Status string
|
||||
Reason string
|
||||
CreatedBy *string
|
||||
CreatedAt time.Time
|
||||
ID string
|
||||
PaymentID string
|
||||
BookingID string
|
||||
Amount float64
|
||||
SquareRefundID *string
|
||||
Status string
|
||||
Reason string
|
||||
CreatedBy *string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type PaymentSummary struct {
|
||||
@@ -77,19 +77,39 @@ type PaymentSummary struct {
|
||||
|
||||
func (s *PaymentService) CalculateFees(amount int64, method string) float64 {
|
||||
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) {
|
||||
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
|
||||
if record.BookingID != "" {
|
||||
bookingID = &record.BookingID
|
||||
}
|
||||
|
||||
var id string
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
err := q.QueryRow(ctx, `
|
||||
INSERT INTO payments (
|
||||
booking_id, payment_type, payment_method, vendor_code, invoice_number,
|
||||
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
|
||||
@@ -326,14 +346,6 @@ func (s *PaymentService) GetAlreadyRefundedAmount(ctx context.Context, paymentID
|
||||
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) {
|
||||
var count int
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
@@ -355,6 +367,44 @@ func (s *PaymentService) GetBookingStatus(ctx context.Context, bookingID string)
|
||||
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) {
|
||||
var userID string
|
||||
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
|
||||
}
|
||||
|
||||
var SquareClient square.SquareClient
|
||||
var SquareClient square.SquareClient
|
||||
|
||||
@@ -18,11 +18,11 @@ import (
|
||||
)
|
||||
|
||||
type TillSaleRequest struct {
|
||||
ItemType string `json:"item_type"`
|
||||
Action string `json:"action"`
|
||||
Amount float64 `json:"amount"`
|
||||
ItemType string `json:"item_type" validate:"required"`
|
||||
Action string `json:"action" validate:"required"`
|
||||
Amount float64 `json:"amount" validate:"required,gt=0"`
|
||||
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"`
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
@@ -53,6 +53,14 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := validators.Validate.Struct(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// M8
|
||||
// L5
|
||||
|
||||
if req.ItemType != "gift_card" {
|
||||
http.Error(w, "Unsupported item type", http.StatusBadRequest)
|
||||
return
|
||||
|
||||
@@ -58,4 +58,4 @@ func ValidateCardInfo(cardID, newCardToken *string) error {
|
||||
return errors.New("either card_id or new_card_token is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user