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
+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()
}