Fix payment review round: till integrity, HTTP client tests, concurrency tests, card-selection consolidation

Addresses the payment review (all 10 blocking + 2 minor findings):

Till money-integrity (CreateTillSale):
- Add pg_advisory_lock on the idempotency key (concurrent same-key double-funding race)
- Guard amount on pending-reuse retry (mirrors tip/gift-card guards)
- Explicitly complete the row for cash/on_the_house pending-reuse
- Reject method-switch on a live card-machine checkout (double-charge guard)
- 3 regression tests (amount-mismatch, cash-completes-row, method-switch)

BookingFlow:
- Fetch saved cards at the deposit step (was dead code)
- Charge the server-computed deposit_amount, not the client estimate

HTTP client tests (was untested): doJSON error parsing, refund sentinel
classification, payment/refund/card wire shapes, checkout polling states,
list-refunds pagination + 20-page guard, sha256 card idempotency key

Concurrency regression tests: real two-goroutine races for BuyGiftCard,
tip, and booking-payment locks asserting exactly-one record each

Frontend:
- Fix CRIT-1: zero-saved-card users blocked (all flows now handle it)
- Consolidate tip/deposit/Buy-Gift-Card card UI onto CardSelection
- Explicit save-card consent checkbox (was silent/inconsistent)
- Fix stale saved-card field names in BookingFlow (last4 -> last_4)
- Unique instance ids (crypto.randomUUID) in CardSelection/SquareCardInput
- UserPaymentModal: keep card form mounted on error + Try Again button

Health/docs: /api/health reports square state (mock/ok, was not_implemented),
close P1 backlog, correct stale webhook and env-var claims
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 64d4b65083
commit 53ca89603d
18 changed files with 1330 additions and 480 deletions
+20 -5
View File
@@ -279,7 +279,10 @@ type sqDisableCardResponse struct {
// ---------------------------------------------------------------------------
func createPaymentHTTP(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
hc := newHTTPClient()
return createPaymentHTTPWithClient(ctx, req, newHTTPClient())
}
func createPaymentHTTPWithClient(ctx context.Context, req CreatePaymentReq, hc *httpClient) (*PaymentResult, error) {
body := sqCreatePaymentRequest{
SourceID: req.SourceID,
IdempotencyKey: req.IdempotencyKey,
@@ -327,7 +330,10 @@ func createCheckoutHTTPWithClient(ctx context.Context, req CreateCheckoutReq, hc
}
func getCheckoutHTTP(ctx context.Context, checkoutID string) (*PaymentResult, error) {
hc := newHTTPClient()
return getCheckoutHTTPWithClient(ctx, checkoutID, newHTTPClient())
}
func getCheckoutHTTPWithClient(ctx context.Context, checkoutID string, hc *httpClient) (*PaymentResult, error) {
var tcResp sqTerminalCheckoutResponse
if err := hc.doJSON(ctx, http.MethodGet, "/v2/terminals/checkouts/"+checkoutID, nil, &tcResp); err != nil {
return nil, err
@@ -374,7 +380,10 @@ var definitiveRefundCodes = map[string]bool{
}
func refundPaymentHTTP(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
hc := newHTTPClient()
return refundPaymentHTTPWithClient(ctx, req, newHTTPClient())
}
func refundPaymentHTTPWithClient(ctx context.Context, req RefundPaymentReq, hc *httpClient) (*RefundResult, error) {
body := sqRefundPaymentRequest{
PaymentID: req.PaymentID,
IdempotencyKey: req.IdempotencyKey,
@@ -396,7 +405,10 @@ func refundPaymentHTTP(ctx context.Context, req RefundPaymentReq) (*RefundResult
}
func listRefundsHTTP(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
hc := newHTTPClient()
return listRefundsHTTPWithClient(ctx, paymentID, beginTime, newHTTPClient())
}
func listRefundsHTTPWithClient(ctx context.Context, paymentID string, beginTime time.Time, hc *httpClient) ([]RefundResult, error) {
base := "/v2/refunds?begin_time=" + url.QueryEscape(beginTime.UTC().Format(time.RFC3339)) + "&limit=100"
path := base
results := []RefundResult{}
@@ -420,7 +432,10 @@ func listRefundsHTTP(ctx context.Context, paymentID string, beginTime time.Time)
}
func createCardOnFileHTTP(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
hc := newHTTPClient()
return createCardOnFileHTTPWithClient(ctx, userID, cardToken, newHTTPClient())
}
func createCardOnFileHTTPWithClient(ctx context.Context, userID, cardToken string, hc *httpClient) (*CardOnFile, error) {
// Deterministic idempotency key derived from user + card (not time-based)
// so that retries with the same details don't create duplicate cards.