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)
234 lines
8.6 KiB
Go
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")
|
|
}
|