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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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, `
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user