fix: loop-B full-scope adversarial findings — tip-excluded detail endpoints, £0-charge guard, 24h window, 2FA single-use everywhere
Loop B full-scope red-team (money/security/dup-mod) findings: - CRITICAL: booking detail handlers (GetBookingHandler/GetAdminBookingHandler) now exclude payment_type='tip' from amount_paid — a tip before the final balance no longer undercharges the booking (bookings.go x3 sites) - HIGH: A6 deposit clamp adds a zero-guard — when the eligible discount covers the entire deposit, the flow returns deposit_covered_by_discount instead of charging £0 at Square (real Square rejects £0; the mock accepted it); square_dev CreatePayment + CreateRefund now reject Amount <= 0 (mock/prod parity) - HIGH: replayLegitimateRetryWindow restored to 22h (== stalePendingKeyedAge) so sweep-produced duplicate charges are still auto-refunded, not rescued-and-hidden - HIGH: 2FA single-use consume-at-gate applied to ALL saved-card charge gates (booking 2263, admin saved-card 960, tip 4483, till 967, gift-card purchase 1482) with re-issue-on-failed-charge on each; pending-reuse retries keep their code - MEDIUM: 2FA re-issue now fires only when the gate actually consumed a code (fresh saved-card path) — new-card failures no longer silently burn a standing code - MEDIUM: pre_start tip-exclusion consistent across admin lists + detail handlers (bookings.go) - MEDIUM: remaining-balance counts pending refunds (service.go) — capacity consistent with GetBookingPaymentInfo - Mock CreatePayment/CreateRefund reject £0 amounts (INVALID_REQUEST_ERROR) for dev/prod parity 26/26 backend packages; 80/80 frontend tests + build; env-docs 41/41.
This commit is contained in:
@@ -681,6 +681,7 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
SELECT COALESCE(SUM(amount), 0) AS pre_start_amount_paid
|
||||
FROM payments
|
||||
WHERE booking_id = b.id AND status = 'completed' AND created_at < b.start_time
|
||||
AND payment_type <> 'tip'
|
||||
) pre_pay ON true
|
||||
`
|
||||
|
||||
@@ -1287,7 +1288,13 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
p.InvoiceNumber = &num
|
||||
}
|
||||
booking.Payments = append(booking.Payments, p)
|
||||
if p.Status == "completed" {
|
||||
// A tip is gratuity paid beyond the booking total — it must not reduce
|
||||
// the balance owed. This mirrors the list views (which filter
|
||||
// payment_type <> 'tip' in SQL) and GetBookingPaymentInfo; without the
|
||||
// exclusion AmountDue would be understated by the tip and the frontend
|
||||
// would charge less than the true remaining balance, leaving the
|
||||
// booking never completed and the merchant short.
|
||||
if p.Status == "completed" && p.PaymentType != "tip" {
|
||||
amountPaid += p.Amount
|
||||
if p.CreatedAt.Before(booking.StartTime) {
|
||||
preStartAmountPaid += p.Amount
|
||||
@@ -1679,7 +1686,13 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
p.InvoiceNumber = &num
|
||||
}
|
||||
booking.Payments = append(booking.Payments, p)
|
||||
if p.Status == "completed" {
|
||||
// A tip is gratuity paid beyond the booking total — it must not reduce
|
||||
// the balance owed. This mirrors the list views (which filter
|
||||
// payment_type <> 'tip' in SQL) and GetBookingPaymentInfo; without the
|
||||
// exclusion AmountDue would be understated by the tip and the frontend
|
||||
// would charge less than the true remaining balance, leaving the
|
||||
// booking never completed and the merchant short.
|
||||
if p.Status == "completed" && p.PaymentType != "tip" {
|
||||
amountPaid += p.Amount
|
||||
if p.CreatedAt.Before(booking.StartTime) {
|
||||
preStartAmountPaid += p.Amount
|
||||
@@ -1775,6 +1788,7 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
SELECT COALESCE(SUM(amount), 0) AS pre_start_amount_paid
|
||||
FROM payments
|
||||
WHERE booking_id = b.id AND status = 'completed' AND created_at < b.start_time
|
||||
AND payment_type <> 'tip'
|
||||
) pre_pay ON true
|
||||
-- NOTE: ILIKE with leading wildcard prevents B-tree index usage.
|
||||
-- At scale, replace with pg_trgm GIN index: CREATE INDEX idx_bookings_search_trgm ON bookings USING GIN (id gin_trgm_ops, notes gin_trgm_ops);
|
||||
@@ -3304,7 +3318,13 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
p.CreatedBy = &pCreatedBy.String
|
||||
}
|
||||
|
||||
if p.Status == "completed" {
|
||||
// A tip is gratuity paid beyond the booking total — it must not reduce
|
||||
// the balance owed. This mirrors the list views (which filter
|
||||
// payment_type <> 'tip' in SQL) and GetBookingPaymentInfo; without the
|
||||
// exclusion AmountDue would be understated by the tip and the frontend
|
||||
// would charge less than the true remaining balance, leaving the
|
||||
// booking never completed and the merchant short.
|
||||
if p.Status == "completed" && p.PaymentType != "tip" {
|
||||
amountPaid += p.Amount
|
||||
if p.CreatedAt.Before(booking.StartTime) {
|
||||
preStartAmountPaid += p.Amount
|
||||
|
||||
Reference in New Issue
Block a user