Close refund system and gate raw-PAN card entry
Refund system (Round 3 fixes + follow-up + alignment): - Serialize cancellation refunds against the manual handler via per-payment advisory locks taken before the prior-refunds read (pg_advisory_xact_lock, ascending, same crussell:refund: key space) - Aggregate pending cancellation refunds into ONE Square refund per charge (stable charge-level -square-agg key); atomic group UPDATE keeps crash-retry amounts identical for Square key-dedup - Persist paymentID-square-amount idempotency keys on cancellation refunds; scheduler reads the stored key (legacy fallback for old rows) - Add sweep-pending-square-refunds cron (*/5, concurrency 1) with refund_attempts cap; sweep retries stale manual pending refunds with each row's own stored idempotency key - Reconcile at Square (GET /v2/refunds ListPaymentRefunds) before every terminal failed transition: tri-state result leaves rows pending on reconcile error instead of false-failing; PAYMENT_ALREADY_REFUNDED resolves to completed - Move over-refund guard inside the lock, counting completed + pending (excluding failed); ErrRefundDeclined distinguishes definitive vs ambiguous outcomes - forgiveFees now executes a real full refund (forceFullRefund override) with admin_forgiven_fees reason threaded to Square - Surface failed card refunds in the admin notification centre (refund_failed enum, RETURNING-id pre-pass inserts, NOT EXISTS dedup) - Dedup double-cancel refund inserts via ON CONFLICT (idempotency_key) DO NOTHING without consuming refundRemaining Frontend: - Remove all raw-PAN card entry: zero card_number/card_cvc/new_card_token in request bodies; gate new-card entry behind CardEntryUnavailable notice + newCardDisabled prop across all 8 flows - Delete hand-rolled CardInput.svelte; keep CardSelection saved-card UI and CardEntryUnavailable fallback - Update cancellation-policy page to in-person cash pickup wording Tests: - Rewrite the two amount-blind dedup tests to assert real money movement (single call, aggregated amount, shared refund ID) - Add coverage: manual refund vs cancellation serialization (concurrent goroutines), reconcile error vs no-match branches, stale manual retry, forgive-fees real refund row + reason, double-cancel dedup, mock refund key dedup, ListPaymentRefunds filtering - Fix time-dependent booking flakes with fixtures.NextWorkingDayAt - 25/25 packages pass; -race clean on payments/square/db/jobs/bookings
This commit is contained in:
@@ -91,7 +91,8 @@ func (c *httpClient) doJSON(ctx context.Context, method, path string, body, targ
|
||||
var errResp struct{ Errors []SquareError `json:"errors"` }
|
||||
if json.Unmarshal(respBody, &errResp) == nil && len(errResp.Errors) > 0 {
|
||||
se := errResp.Errors[0]
|
||||
return fmt.Errorf("square: %s %s: [%s/%s] %s (field: %s)", method, path, se.Category, se.Code, se.Detail, se.Field)
|
||||
msg := fmt.Sprintf("square: %s %s: [%s/%s] %s (field: %s)", method, path, se.Category, se.Code, se.Detail, se.Field)
|
||||
return &squareAPIError{Code: se.Code, Detail: se.Detail, err: errors.New(msg)}
|
||||
}
|
||||
return fmt.Errorf("square: %s %s: HTTP %d: %s", method, path, resp.StatusCode, string(respBody))
|
||||
}
|
||||
@@ -230,6 +231,11 @@ type sqRefundPaymentResponse struct {
|
||||
Refund sqRefund `json:"refund"`
|
||||
}
|
||||
|
||||
type sqListRefundsResponse struct {
|
||||
Refunds []sqRefund `json:"refunds"`
|
||||
Cursor string `json:"cursor"`
|
||||
}
|
||||
|
||||
type sqRefund struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
@@ -343,6 +349,30 @@ func getCheckoutHTTP(ctx context.Context, checkoutID string) (*PaymentResult, er
|
||||
return paymentFromSquare(&payResp.Payment), nil
|
||||
}
|
||||
|
||||
// squareAPIError wraps a formatted Square API error while exposing the
|
||||
// structured Square error code so callers can classify definitive business
|
||||
// rejections (e.g. ErrRefundDeclined) vs ambiguous transport/server errors.
|
||||
type squareAPIError struct {
|
||||
Code string
|
||||
Detail string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *squareAPIError) Error() string { return e.err.Error() }
|
||||
func (e *squareAPIError) Unwrap() error { return e.err }
|
||||
|
||||
// Definitive Square refund rejection codes — the refund was declined and can
|
||||
// never succeed, so retrying is pointless and the refund record should be
|
||||
// marked 'failed'. Anything else (transport errors, 5xx) is left ambiguous so
|
||||
// callers leave the refund 'pending' for a scheduler retry. Note that
|
||||
// PAYMENT_ALREADY_REFUNDED is intentionally absent — the money has already
|
||||
// moved, so it maps to ErrRefundAlreadyProcessed instead of ErrRefundDeclined.
|
||||
var definitiveRefundCodes = map[string]bool{
|
||||
"REFUND_DECLINED": true,
|
||||
"PAYMENT_REFUND_AMOUNT_EXCEEDED": true,
|
||||
"INVALID_PAYMENT_ID": true,
|
||||
}
|
||||
|
||||
func refundPaymentHTTP(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
||||
hc := newHTTPClient()
|
||||
body := sqRefundPaymentRequest{
|
||||
@@ -353,11 +383,42 @@ func refundPaymentHTTP(ctx context.Context, req RefundPaymentReq) (*RefundResult
|
||||
}
|
||||
var resp sqRefundPaymentResponse
|
||||
if err := hc.doJSON(ctx, http.MethodPost, "/v2/refunds", body, &resp); err != nil {
|
||||
var sqErr *squareAPIError
|
||||
if errors.As(err, &sqErr) && definitiveRefundCodes[sqErr.Code] {
|
||||
return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, err)
|
||||
}
|
||||
if errors.As(err, &sqErr) && sqErr.Code == "PAYMENT_ALREADY_REFUNDED" {
|
||||
return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return refundFromSquare(&resp.Refund), nil
|
||||
}
|
||||
|
||||
func listRefundsHTTP(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
|
||||
hc := newHTTPClient()
|
||||
base := "/v2/refunds?begin_time=" + url.QueryEscape(beginTime.UTC().Format(time.RFC3339)) + "&limit=100"
|
||||
path := base
|
||||
results := []RefundResult{}
|
||||
for page := 0; page < 20; page++ {
|
||||
var resp sqListRefundsResponse
|
||||
if err := hc.doJSON(ctx, http.MethodGet, path, nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range resp.Refunds {
|
||||
r := &resp.Refunds[i]
|
||||
if r.PaymentID == paymentID {
|
||||
results = append(results, *refundFromSquare(r))
|
||||
}
|
||||
}
|
||||
if resp.Cursor == "" {
|
||||
return results, nil
|
||||
}
|
||||
path = base + "&cursor=" + url.QueryEscape(resp.Cursor)
|
||||
}
|
||||
return nil, fmt.Errorf("square: list refunds exceeded 20 pages (infinite loop guard)")
|
||||
}
|
||||
|
||||
func createCardOnFileHTTP(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
|
||||
hc := newHTTPClient()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user