fix: review round 7 — fresh-eyes audit fixes (6 agents) + full test suites for every backend change
Fresh-eyes review round with 6 independent agents (money-safety, concurrency, Square wire parity, security, frontend flow, testing-gaps). Every finding was independently verified against the code before fixing. All backend changes now carry full test suites (10+ new tests, each verified to FAIL without its guard). All 20 packages green, race detector clean. Money-safety: - Gift-card purchase refunds no longer create money: manual refunds of a no-booking (gift-card purchase) payment are rejected with a clear message in the direct handler AND never re-issued by the sweep-resume path (processManualPaymentGroup skips them; reconcile-then-fail, no re-issue). - BuyGiftCard no-client-key fallback: derived deterministically under the advisory lock (pending-row reuse fixes lost-response double-charge; completed-row sequence advance preserves distinct-purchase collapse fix). - Terminal completion is never unrecorded: activeTerminalCheckoutID now calls recordUntrackedTerminalPayment when a provisional (tmp-) checkout is found COMPLETED at Square (previously only marked the row COMPLETED — a lost poll left the payment invisible and unrefundable). - Sweep: provisional tmp- checkout rows are resolved against Square first (COMPLETED → record; live → keep guard; NOT_FOUND/CANCELED → fail; ambiguous → leave pending) instead of blind-failing a possibly-live checkout. recordUntrackedTerminalPayment re-checks the booking status (FOR UPDATE) and refuses to record on a cancelled booking, inserting a critical_payment_log admin notification instead. Till-sale post-charge UPDATE now requires status='pending' (no resurrection of a clawed-back sale). Frontend (Svelte 5): - UserPaymentModal keeps CardSelection mounted through processing (bind:this ref + Square iframe survive the loyalty/tokenize awaits) — new-card payments work again. - BookingFlow clears the cached nonce/verification pair on any failure (retry re-tokenizes fresh; idempotency key retained for dedup); 409 'already paid' refetches the booking and reconciles depositPaid so the confirmation gate opens; Back button disabled during processing. - Synchronous double-submit guards on buyGiftCard/redeemGiftCard/submitTip. Square wire parity (mock vs real): - processing_fee sign unified (negated at paymentFromSquare; mock agrees). - SimulateSourceUsed (SOURCE_USED, 400) matches real CreateCard. - GetCardsOnFile excludes disabled cards (matches ListCards). - ForcePaymentStatus toggle + tests prove the charge path can't be status-blind. - CreateCheckout rejects empty device_id (env fallback SQUARE_TERMINAL_DEVICE_ID); completed terminal checkout's payment resolvable by id. Security: - 2FA attempt-map data race fixed: lastAt is atomic.Int64 (nanos) — eviction scan reads race-free; concurrent verify+evict tests under -race. - Backend refuses to start on weak/placeholder JWT_SECRET_KEY (<32 chars or known public placeholders) with openssl rand -hex 32 guidance. - Dockerfile no longer COPYs .env (secrets injected via compose env_file). - SabreDAV requires DAV_ADMIN_PASSWORD (no admin/admin default); compose fails at config time when missing. Testing gaps closed (each verified to FAIL without its guard): - refunded-dedup 409 (CreateBookingPayment), keyed sweep past-retention blind-fail, reconcile status-switch (CANCELED/FAILED/APPROVED/PENDING/unknown in both by-key and by-id paths), resolveChargeSource Square-failure branches, structured 500 / CARD_DECLINED / cancelled-context E2E (row stays pending), deriveBookingPaymentIdempotencyKey >45-char truncation, webhook findPaymentByDisputeID fallback, clawbackOneTillSale non-gift-card branch, dispute.evidence / terminal.checkout dispatch. Infra: - local-dev-2.sh fails loudly on port-5432 squatters / docker compose failures (previously died silently under ERR_EXIT with hidden output). - Test harness defaults SQUARE_TERMINAL_DEVICE_ID; money_safety_fixes_test.go gained the missing build tag. Verification: go test -tags test,dev -count=1 -parallel 8 ./... (20/20 ok), -race clean on 2FA + payments money paths, go build ./... + -tags dev, go vet clean, svelte-check 0 errors, env-docs gate OK (36 vars), docker compose config valid.
This commit is contained in:
@@ -1686,3 +1686,100 @@ func TestDeleteCustomerHTTP(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPaymentFromSquare_NegatesProcessingFees locks the processing-fee sign
|
||||
// convention (finding A): Square reports processing_fee amounts as NEGATIVE on
|
||||
// the wire, and paymentFromSquare must surface a POSITIVE PaymentResult.Fees
|
||||
// (the magnitude handlers store as p.fees). A fee of -95 on the wire must
|
||||
// become Fees == 95.
|
||||
func TestPaymentFromSquare_NegatesProcessingFees(t *testing.T) {
|
||||
p := &sqPayment{
|
||||
ID: "pay_fee",
|
||||
Status: "COMPLETED",
|
||||
TotalMoney: sqMoney{Amount: 5000, Currency: "GBP"},
|
||||
ProcessingFee: []sqFee{
|
||||
{AmountMoney: sqMoney{Amount: -95, Currency: "GBP"}, Type: "INITIAL"},
|
||||
{AmountMoney: sqMoney{Amount: -20, Currency: "GBP"}, Type: "SECONDARY"},
|
||||
},
|
||||
}
|
||||
result := paymentFromSquare(p)
|
||||
if result.Fees != 115 {
|
||||
t.Errorf("expected Fees 115 (sum of negated Square fees), got %d", result.Fees)
|
||||
}
|
||||
|
||||
// A zero fee stays zero.
|
||||
zero := paymentFromSquare(&sqPayment{ID: "pay_zero", Status: "COMPLETED", ProcessingFee: []sqFee{{AmountMoney: sqMoney{Amount: 0, Currency: "GBP"}}}})
|
||||
if zero.Fees != 0 {
|
||||
t.Errorf("expected Fees 0 for a zero fee, got %d", zero.Fees)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentFromSquare_StatusMapping verifies paymentFromSquare maps every
|
||||
// documented Square payment status FAITHFULLY (finding D): APPROVED, PENDING,
|
||||
// FAILED, CANCELED and COMPLETED all flow through into PaymentResult.Status.
|
||||
// The client never downgrades or drops a non-terminal status.
|
||||
func TestPaymentFromSquare_StatusMapping(t *testing.T) {
|
||||
for _, status := range []string{"APPROVED", "PENDING", "FAILED", "CANCELED", "COMPLETED"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
p := &sqPayment{ID: "pay_" + status, Status: status, TotalMoney: sqMoney{Amount: 5000, Currency: "GBP"}}
|
||||
result := paymentFromSquare(p)
|
||||
if result.Status != status {
|
||||
t.Errorf("expected Status %q mapped verbatim, got %q", status, result.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoJSON_200WithFailedPayment_NotDropped verifies doJSON does NOT silently
|
||||
// drop a 200-with-FAILED payment (finding D): a 2xx body carrying a FAILED
|
||||
// payment is parsed into a PaymentResult with Status "FAILED" and nil error —
|
||||
// the client surfaces the status faithfully instead of erroring, so a
|
||||
// status-blind handler (records 'completed' on nil error alone) is exposed.
|
||||
func TestDoJSON_200WithFailedPayment_NotDropped(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"payment":{"id":"pay_failed_200","status":"FAILED","total_money":{"amount":5000,"currency":"GBP"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||
res, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "ik-failed-200",
|
||||
}, hc)
|
||||
if err != nil {
|
||||
t.Fatalf("a 200-with-FAILED payment must NOT error (the client is status-transparent), got %v", err)
|
||||
}
|
||||
if res.Status != "FAILED" {
|
||||
t.Errorf("expected Status FAILED surfaced faithfully, got %q", res.Status)
|
||||
}
|
||||
if res.ID != "pay_failed_200" {
|
||||
t.Errorf("expected the failed payment returned, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoJSON_CardProcessingNotEnabled403 verifies a 403 CARD_PROCESSING_NOT_ENABLED
|
||||
// response surfaces the structured Square error with StatusCode 403 (finding E)
|
||||
// so the handlers agent can special-case it (errors.go, not owned here).
|
||||
func TestDoJSON_CardProcessingNotEnabled403(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"errors":[{"category":"PAYMENT_METHOD_ERROR","code":"CARD_PROCESSING_NOT_ENABLED","detail":"Card processing is not enabled for this account."}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||
err := hc.doJSON(context.Background(), http.MethodPost, "/v2/payments", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 403")
|
||||
}
|
||||
if got := ErrorStatusCode(err); got != http.StatusForbidden {
|
||||
t.Errorf("expected ErrorStatusCode 403, got %d", got)
|
||||
}
|
||||
if got := ErrorCode(err); got != "CARD_PROCESSING_NOT_ENABLED" {
|
||||
t.Errorf("expected ErrorCode CARD_PROCESSING_NOT_ENABLED, got %q", got)
|
||||
}
|
||||
if got := ErrorCategory(err); got != "PAYMENT_METHOD_ERROR" {
|
||||
t.Errorf("expected ErrorCategory PAYMENT_METHOD_ERROR, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user