//go:build test && dev package payments import ( "net/http" "testing" "crussell/internal/square" "crussell/testutils" "crussell/testutils/jwt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // ============================================================================= // M5: Fully-paid bookings auto-complete // ============================================================================= // TestBookingPayment_FullyPaid_CompletesBooking verifies that an online // payment covering 100% of the booking total transitions an active booking to // 'completed' so it leaves the admin's Current Appointment view. func TestBookingPayment_FullyPaid_CompletesBooking(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) // setupTestData creates a £50 (5000 pence) booking with status in_progress. userID, bookingID, _ := setupTestData(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:fully-paid-complete" req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "fully-paid-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) var status string err := tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status) require.NoError(t, err) assert.Equal(t, "completed", status, "a fully-paid booking must auto-complete") } // TestBookingPayment_PartialPayment_DoesNotComplete verifies that a partial // payment (below 100%) leaves the booking in its active status. func TestBookingPayment_PartialPayment_DoesNotComplete(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) // £25 = 50% of the £50 booking total. cardToken := "cnon:partial-no-complete" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "partial", NewCardToken: &cardToken, IdempotencyKey: "partial-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) var status string err := tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status) require.NoError(t, err) assert.Equal(t, "in_progress", status, "a partial payment must not complete the booking") } // TestBookingPayment_FullPaymentPlusTip_Completes verifies that a full payment // plus a later tip both succeed — the booking completes on the full payment // and the tip is still accepted on the completed booking. func TestBookingPayment_FullPaymentPlusTip_Completes(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) // setupTestDataPast: booking started 1 hour ago, status in_progress. userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:full-plus-tip" req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "full-plus-tip-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) var status string err := tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status) require.NoError(t, err) assert.Equal(t, "completed", status) // The tip is gratuity for a service already rendered — still accepted on a // completed booking (bookingStatusAllowsCompletedPayment includes it). tipToken := "cnon:tip-on-completed" tipReq := CreateTipPaymentRequest{ Amount: 500, NewCardToken: &tipToken, } handler = CreateTipPayment w = makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", tipReq, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) var tipCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&tipCount) require.NoError(t, err) assert.Equal(t, 1, tipCount, "the tip must be recorded on the completed booking") err = tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status) require.NoError(t, err) assert.Equal(t, "completed", status, "a tip must not revert the completed status") } // TestTerminalPayment_FullyPaid_CompletesBooking verifies that a Square // Terminal (card-machine) charge covering 100% of the booking total also // auto-completes the booking. func TestTerminalPayment_FullyPaid_CompletesBooking(t *testing.T) { origClient := SquareClient SquareClient = &testCheckoutClient{ SquareClient: square.NewDevClient(), hexIDs: make(map[string]string), } defer func() { SquareClient = origClient }() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminToken := jwt.GenerateAdminToken() handler := CreateTerminalPayment req := CreateTerminalPaymentRequest{ Amount: 5000, PaymentType: "full", TipEnabled: false, } w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) var createResp CheckoutResponse if err := parsePaymentResponseBody(w, &createResp); err != nil { t.Fatalf("failed to parse create response: %v", err) } require.NotEmpty(t, createResp.CheckoutID) resp := pollCheckoutStatus(t, ctx, createResp.CheckoutID, bookingID, adminToken) require.Equal(t, "COMPLETED", resp.Status) require.NotEmpty(t, resp.PaymentID) var status string err := tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status) require.NoError(t, err) assert.Equal(t, "completed", status, "a fully-paid terminal charge must auto-complete the booking") } // TestFullyPaid_CancelledBooking_StaysCancelled verifies that a cancelled // booking can never be auto-completed by a payment: the payment is rejected // and the status is unchanged. func TestFullyPaid_CancelledBooking_StaysCancelled(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) _, err := tx.Exec(ctx, `UPDATE bookings SET status = 'client_cancelled' WHERE id = $1`, bookingID) require.NoError(t, err) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:cancelled-booking" req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "cancelled-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusConflict, w.Code, "body: %s", w.Body.String()) var status string err = tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status) require.NoError(t, err) assert.Equal(t, "client_cancelled", status, "a cancelled booking must never be completed by a payment") var payCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount) require.NoError(t, err) assert.Equal(t, 0, payCount, "no payment may be recorded on a cancelled booking") } // TestBookingPayment_FullyPaid_AwardsLoyaltyStamp verifies that the loyalty // stamp is awarded by the payment-driven completion, mirroring the admin // progress endpoint. func TestBookingPayment_FullyPaid_AwardsLoyaltyStamp(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:loyalty-stamp" req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "loyalty-stamp-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) var status string err := tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status) require.NoError(t, err) require.Equal(t, "completed", status) var stamps int err = tx.QueryRow(ctx, `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&stamps) require.NoError(t, err) assert.Equal(t, 1, stamps, "a payment-completed booking must award one loyalty stamp") }