Fix tip amount-change false dedup, wire BuyerEmail everywhere, clear ESLint errors

Money-moving fixes:
- Tip idempotency key regenerates when the tip amount changes after a failed
  attempt (all 3 tip flows). Cached key still reused on same-amount retry
  (dedup intact) and cleared on success/modal reset. Prevents silent
  under-charge when a user retries at a different amount.
- Till replay path returns actual till_sales.status (may be 'pending') instead
  of hardcoded 'completed' — no more misreported successful charge.
- BuyerEmail wired for CreateBookingPayment, gift card purchases, and till
  sales (saved_card + online_square), matching the tip flow. Email lookup
  errors logged, non-fatal.
- Till buyer-email errors now logged (was silently swallowed).
- on_the_house till top-up uses cached getIdempotencyKey() for retry-safe dedup
  (was fresh crypto.randomUUID()).

Test/validation fixes:
- Add TestPaymentFromSquare_* unit tests (else-branch + nil card details),
  build tag relaxed to 'test' so they run in the standard dev suite.
- Add TestValidateCardInfo table test (7 cases: both/either/neither/empty).
- Add TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed regression test.
- Remove dead mock pre-registration in TestTipPayment_WithSavedCard.
- Correct misleading till regression-test comment.

ESLint cleanup (12 errors -> 0):
- Remove unused loadingCards in tip + pay-tip pages (dead assignments in
  loadSavedCards).
- Scoped eslint-disable for {@html} in CardBrandIcon (hardcoded brand SVGs).
- Remove dead confirmSaveDefaultHours + unused rescheduleVersion prop in
  WeeklySchedule (and its parent pass-through).
- Replace new Date() with SvelteDate in WeeklySchedule + BusinessHours.
- Fix each-block key in BusinessHours skeleton loader.
- Use void expression for reactivity-tracker reads in effects.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent a4441b6acf
commit 28f0ddb328
15 changed files with 330 additions and 143 deletions
+6
View File
@@ -999,12 +999,18 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
// Step 2: DB transaction committed — safe to call Square now.
// If Square fails, the payment record stays 'pending' for manual retry.
var buyerEmail string
if err := db.Conn.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&buyerEmail); err != nil {
log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", userID, err)
}
paymentReq := square.CreatePaymentReq{
Amount: req.Amount,
Currency: "GBP",
SourceID: sourceID,
IdempotencyKey: req.IdempotencyKey,
Note: "Gift Card Purchase",
BuyerEmail: buyerEmail,
}
paymentResult, err := SquareClient.CreatePayment(ctx, paymentReq)
+28 -5
View File
@@ -2,6 +2,7 @@ package payments
import (
"context"
"crypto/rand"
"crussell/clock"
"crussell/db"
"crussell/internal/square"
@@ -47,10 +48,11 @@ type RefundRequest struct {
}
type CreateTipPaymentRequest struct {
Amount int64 `json:"amount" validate:"required,gt=0"`
CardID *string `json:"card_id,omitempty"`
NewCardToken *string `json:"new_card_token,omitempty"`
SaveCard bool `json:"save_card"`
Amount int64 `json:"amount" validate:"required,gt=0"`
CardID *string `json:"card_id,omitempty"`
NewCardToken *string `json:"new_card_token,omitempty"`
SaveCard bool `json:"save_card"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
}
type CheckoutResponse struct {
@@ -759,6 +761,12 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
return
}
// Resolve buyer email for Square receipt delivery (failure is non-fatal).
var bookingBuyerEmail string
if err := db.Conn.QueryRow(r.Context(), `SELECT email FROM users WHERE id = $1`, userID).Scan(&bookingBuyerEmail); err != nil {
log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", userID, err)
}
// M8
// L5
@@ -980,6 +988,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
IdempotencyKey: req.IdempotencyKey,
ReferenceID: bookingID,
Note: req.PaymentType,
BuyerEmail: bookingBuyerEmail,
}
paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq)
@@ -1801,7 +1810,14 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
return
}
idempotencyKey := bookingID + "-tip-" + strconv.FormatInt(req.Amount, 10)
// Idempotency key: prefer the client-supplied UUID (one per attempt, so
// two legitimate identical tips on the same booking don't collapse into
// one). Fall back to a unique key when absent — must NOT be derived from
// request fields alone (bookingID + amount would dedupe distinct tips).
idempotencyKey := req.IdempotencyKey
if idempotencyKey == "" {
idempotencyKey = uniqueTipKey()
}
// Resolve the card source ID — same pattern as CreateBookingPayment.
var sourceID string
@@ -2208,3 +2224,10 @@ func ReleasePaymentLock(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// uniqueTipKey generates a unique idempotency key for tip payments where the
// client did not supply one. Client-supplied UUIDs handle retry dedup; this
// fallback only needs uniqueness so identical tips don't collapse.
func uniqueTipKey() string {
return "tip-" + rand.Text()
}
+32 -13
View File
@@ -15,7 +15,6 @@ import (
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
@@ -31,6 +30,38 @@ func makePaymentRequest(handler http.HandlerFunc, method, path string, body inte
return makePaymentAuthRequest(handler, method, path, body, token, "", ctx)
}
func TestValidateCardInfo(t *testing.T) {
empty := ""
cardID := "card_123"
token := "cnon:test"
tests := []struct {
name string
cardID *string
newToken *string
wantError bool
}{
{"both set rejected", &cardID, &token, true},
{"card_id only ok", &cardID, nil, false},
{"token only ok", nil, &token, false},
{"neither set rejected", nil, nil, true},
{"empty card_id rejected", &empty, nil, true},
{"empty token rejected", nil, &empty, true},
{"both empty rejected", &empty, &empty, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateCardInfo(tt.cardID, tt.newToken)
if tt.wantError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func makePaymentAuthRequest(handler http.HandlerFunc, method, path string, body interface{}, token, userIDOverride string, ctx context.Context) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
@@ -1875,18 +1906,6 @@ func TestTipPayment_WithSavedCard(t *testing.T) {
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePayID, payID)
require.NoError(t, err)
// Register the payment in the mock so the tip flow works.
mockClient, ok := SquareClient.(*square.MockClient)
require.True(t, ok, "SquareClient must be a MockClient for this test")
_, err = mockClient.CreatePayment(ctx, square.CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:visa",
IdempotencyKey: "pay-for-saved-card-tip",
ReferenceID: bookingID,
})
require.NoError(t, err)
// Create a saved card for this user.
var savedCardID string
err = tx.QueryRow(ctx, `
+19 -4
View File
@@ -105,16 +105,17 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
// Idempotency check: if key provided, return existing sale if found
if req.IdempotencyKey != "" {
var existingID string
err := db.Conn.QueryRow(ctx, `SELECT id FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&existingID)
var existingID, existingStatus string
err := db.Conn.QueryRow(ctx, `SELECT id, status FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&existingID, &existingStatus)
if err == nil {
// Existing sale found — return it (idempotent)
// Existing sale found — return its ACTUAL status (may be 'pending'
// if a previous Square charge failed; must not report 'completed').
if err := json.NewEncoder(w).Encode(TillSaleResponse{
ID: existingID,
ItemType: req.ItemType,
TotalAmount: req.Amount,
PaymentMethod: req.PaymentMethod,
Status: "completed",
Status: existingStatus,
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
@@ -408,6 +409,18 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
// Step 2: DB transaction committed — safe to call Square now.
// If Square fails, the till_sale record stays 'pending' for manual retry.
// Resolve buyer email for Square receipt delivery (non-fatal if missing).
var buyerEmail string
if req.UserID != nil && *req.UserID != "" {
if err := db.Conn.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, *req.UserID).Scan(&buyerEmail); err != nil {
log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", *req.UserID, err)
}
}
if buyerEmail == "" {
if err := db.Conn.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, adminID).Scan(&buyerEmail); err != nil {
log.Printf("[SQUARE-PROD] Failed to resolve admin email for user %s: %v (Square receipts will not be emailed)", adminID, err)
}
}
if needsSquarePayment {
var paymentResult *square.PaymentResult
var squareErr error
@@ -419,6 +432,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
SourceID: savedCardSqCardID,
IdempotencyKey: req.IdempotencyKey,
Note: "Gift Card " + req.Action,
BuyerEmail: buyerEmail,
}
paymentResult, squareErr = SquareClient.CreatePayment(ctx, paymentReq)
} else if req.PaymentMethod == "online_square" {
@@ -435,6 +449,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
SourceID: cardOnFile.CardID,
IdempotencyKey: req.IdempotencyKey,
Note: "Gift Card " + req.Action,
BuyerEmail: buyerEmail,
}
paymentResult, squareErr = SquareClient.CreatePayment(ctx, paymentReq)
}
+4 -5
View File
@@ -881,11 +881,10 @@ func TestCreateTillSale_CreateCash(t *testing.T) {
}
}
// TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed is a regression test:
// two identical keyless cash gift-card creations must BOTH return 201. The
// idempotency-key fallback must be unique per request (not derived from request
// fields, which are identical for the two sales) or the second sale would
// collide on the till_sales idempotency_key UNIQUE constraint and return 500.
// TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed verifies that two
// identical keyless cash gift-card creations both return 201. The idempotency-key
// fallback must be unique per request so legitimate repeat sales don't collide
// on the till_sales idempotency_key UNIQUE constraint.
func TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed(t *testing.T) {
t.Parallel()
_, tx := testutils.SetupTestTx(t)
@@ -0,0 +1,45 @@
//go:build test
package square
import "testing"
func TestPaymentFromSquare_ElseBranch_SurfacesBrandWithoutCardID(t *testing.T) {
p := &sqPayment{
ID: "pay_1",
Status: "COMPLETED",
TotalMoney: sqMoney{Amount: 5000, Currency: "GBP"},
CardDetails: &sqCardDetails{
Card: sqCard{
ID: "",
CardBrand: "VISA",
Last4: "4242",
},
},
}
result := paymentFromSquare(p)
if result.CardBrand != "VISA" {
t.Errorf("expected CardBrand VISA, got %s", result.CardBrand)
}
if result.CardLast4 != "4242" {
t.Errorf("expected CardLast4 4242, got %s", result.CardLast4)
}
}
func TestPaymentFromSquare_NilCardDetails(t *testing.T) {
p := &sqPayment{
ID: "pay_2",
Status: "COMPLETED",
TotalMoney: sqMoney{Amount: 2500, Currency: "GBP"},
CardDetails: nil,
}
result := paymentFromSquare(p)
if result.CardBrand != "" {
t.Errorf("expected empty CardBrand when no card details, got %s", result.CardBrand)
}
if result.Amount != 2500 {
t.Errorf("expected amount 2500, got %d", result.Amount)
}
}