Files
Crussell/backend/handlers/payments/m5_fully_paid_completion_test.go
popertots 4b28e93710 fix: tip double-count, fully-paid auto-completion, discount-refund hardening
Tip double-count (root cause of £33.75 vs £28.75 display):
- Remove mock's fixed +500p auto-tip when AllowTipping is true (square_dev.go) —
  real Square only enables a terminal prompt, it never adds a tip to the amount
- Set AllowTipping=false in CreateTerminalPayment: the frontend already embeds
  the tip in the amount, so the terminal must not prompt for a second tip
- M4 tip split now derives the tip as charged amount minus remaining booking
  value ('after 100% is tips'), not from Square's TipAmount field
- Success screens divide paymentResult.amount by 100 (pence -> pounds) in both
  PaymentModal and UserPaymentModal

Fully-paid bookings auto-complete:
- Extract ApplyBookingCompletionSideEffects into payments package (shared by
  admin progress endpoint and payment paths; avoids circular import)
- Add bookingIsFullyPaid + completeFullyPaidBooking: when completed non-tip
  payments reach 100% of the booking total, an active booking transitions to
  'completed' so it leaves the admin Current Appointment view
- Wired into CreateBookingPayment (inside tx) and GetCheckoutStatus (terminal,
  after commit); completion side-effects (loyalty, campaigns, deposits_required)
  fire identically to the manual progress endpoint
- Add /admin/bookings/{id}/refund route (AdminRefundBooking)

Discount-refund hardening:
- RefundPayment explicitly rejects discount/on_the_house payments (was relying
  on the incidental NULL-square_payment_id guard)
- Hide the Refund button for discount/on_the_house payments in EditBookingModal
- Cancel-refund estimate in BookingModal also excludes on_the_house
- Cancellation refund loop + GetBookingPaymentInfo + GetBookingRefundableAmountCents
  exclude payment_type='tip' from refundable totals

Tip flow (start-time guard) fixes tests:
- Tip tests updated to use past-dated bookings (tips now require booking started)

Tests:
- m4_tip_refund_redesign_test.go (tip split, refund exclusion, admin refund cap)
- m5_fully_paid_completion_test.go (online + terminal full-payment completion,
  partial stays active, tip excluded, cancelled stays cancelled)
- Full suite passes with -race (25 packages)
2026-08-22 00:34:49 +01:00

234 lines
8.6 KiB
Go

//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")
}