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:
@@ -2,7 +2,10 @@
|
||||
|
||||
package square
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
var Client SquareClient
|
||||
|
||||
@@ -43,3 +46,7 @@ func (p *ProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardO
|
||||
func (p *ProdClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
|
||||
return deleteCardOnFileHTTP(ctx, cardID)
|
||||
}
|
||||
|
||||
func (p *ProdClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
|
||||
return listRefundsHTTP(ctx, paymentID, beginTime)
|
||||
}
|
||||
|
||||
@@ -30,9 +30,14 @@ type MockClient struct {
|
||||
payments map[string]*PaymentResult
|
||||
paymentByKey map[string]*PaymentResult
|
||||
refunds map[string]*RefundResult
|
||||
refundByKey map[string]*RefundResult
|
||||
completed map[string]*PaymentResult
|
||||
HoldCheckouts bool
|
||||
ShouldFail bool // if true, CreatePayment/RefundPayment return errors for testing error paths
|
||||
// FailRefundCode simulates a specific Square refund rejection code. Empty
|
||||
// = normal success; when set (e.g. "PAYMENT_ALREADY_REFUNDED"),
|
||||
// RefundPayment returns the sentinel-wrapped error for that code.
|
||||
FailRefundCode string
|
||||
}
|
||||
|
||||
type devProdClient struct{}
|
||||
@@ -58,6 +63,9 @@ func (d *devProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]Ca
|
||||
func (d *devProdClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
|
||||
return deleteCardOnFileHTTP(ctx, cardID)
|
||||
}
|
||||
func (d *devProdClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
|
||||
return listRefundsHTTP(ctx, paymentID, beginTime)
|
||||
}
|
||||
|
||||
func NewClient() SquareClient {
|
||||
return NewDevClient()
|
||||
@@ -76,6 +84,7 @@ func NewDevClient() SquareClient {
|
||||
payments: make(map[string]*PaymentResult),
|
||||
paymentByKey: make(map[string]*PaymentResult),
|
||||
refunds: make(map[string]*RefundResult),
|
||||
refundByKey: make(map[string]*RefundResult),
|
||||
completed: make(map[string]*PaymentResult),
|
||||
}
|
||||
}
|
||||
@@ -287,7 +296,15 @@ func (m *MockClient) GetCheckout(ctx context.Context, checkoutID string) (*Payme
|
||||
|
||||
func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
||||
if m.ShouldFail {
|
||||
return nil, fmt.Errorf("mock: refund declined (simulated failure)")
|
||||
return nil, fmt.Errorf("%w: refund declined (simulated failure)", ErrRefundDeclined)
|
||||
}
|
||||
if m.FailRefundCode != "" {
|
||||
switch m.FailRefundCode {
|
||||
case "PAYMENT_ALREADY_REFUNDED":
|
||||
return nil, fmt.Errorf("%w: payment already fully refunded (simulated)", ErrRefundAlreadyProcessed)
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %s (simulated failure)", ErrRefundDeclined, m.FailRefundCode)
|
||||
}
|
||||
}
|
||||
log.Printf("[SQUARE-MOCK] RefundPayment: payment=%s, amount=%d", req.PaymentID, req.Amount)
|
||||
mockSleep(1 * time.Second)
|
||||
@@ -295,6 +312,17 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Real Square dedups on idempotency key: a retry with the same key returns
|
||||
// the original refund rather than issuing a second refund. The mock mirrors
|
||||
// this so dev/testing behaves like production (and the pending-refund
|
||||
// resume path can rely on it).
|
||||
if req.IdempotencyKey != "" {
|
||||
if existing, ok := m.refundByKey[req.IdempotencyKey]; ok {
|
||||
log.Printf("[SQUARE-MOCK] RefundPayment dedup hit: key=%s → id=%s", req.IdempotencyKey, existing.ID)
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
|
||||
now := clock.Now().UTC()
|
||||
refundID := fmt.Sprintf("ref_mock_%d", now.UnixNano())
|
||||
|
||||
@@ -326,6 +354,9 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
}
|
||||
m.refunds[refundID] = result
|
||||
if req.IdempotencyKey != "" {
|
||||
m.refundByKey[req.IdempotencyKey] = result
|
||||
}
|
||||
log.Printf("[SQUARE-MOCK] Refund completed: id=%s, payment=%s, amount=%d", refundID, req.PaymentID, amount)
|
||||
return result, nil
|
||||
}
|
||||
@@ -404,6 +435,26 @@ func (m *MockClient) DeleteCardOnFile(ctx context.Context, cardID string) error
|
||||
return fmt.Errorf("card not found: %s", cardID)
|
||||
}
|
||||
|
||||
func (m *MockClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
|
||||
log.Printf("[SQUARE-MOCK] ListPaymentRefunds: payment=%s, begin=%s", paymentID, beginTime.UTC().Format(time.RFC3339))
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
out := []RefundResult{}
|
||||
for _, r := range m.refunds {
|
||||
if r.PaymentID != paymentID {
|
||||
continue
|
||||
}
|
||||
createdAt, err := time.Parse(time.RFC3339, r.CreatedAt)
|
||||
if err == nil && createdAt.Before(beginTime) {
|
||||
continue
|
||||
}
|
||||
out = append(out, *r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// isTokenLike returns true for Square source_id tokens: cnon:xxx nonces and
|
||||
// ccof:xxx card IDs. Raw PANs (all digits) are NOT token-like and are rejected.
|
||||
func isTokenLike(s string) bool {
|
||||
|
||||
@@ -4,6 +4,7 @@ package square
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -299,6 +300,54 @@ func TestRefundPayment_ShouldFail(t *testing.T) {
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestDevClient_RefundPayment_PaymentAlreadyRefunded(t *testing.T) {
|
||||
// PAYMENT_ALREADY_REFUNDED means the money already moved at Square, so the
|
||||
// mock must return ErrRefundAlreadyProcessed (never ErrRefundDeclined) and
|
||||
// must not store a refund — the caller resolves the record to 'completed'.
|
||||
client := NewDevClient().(*MockClient)
|
||||
client.FailRefundCode = "PAYMENT_ALREADY_REFUNDED"
|
||||
|
||||
ctx := context.Background()
|
||||
req := RefundPaymentReq{
|
||||
PaymentID: "pay_mock_already_refunded",
|
||||
Amount: 5000,
|
||||
IdempotencyKey: "refund-key-already",
|
||||
Reason: "already refunded",
|
||||
}
|
||||
|
||||
result, err := client.RefundPayment(ctx, req)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, ErrRefundAlreadyProcessed), "expected ErrRefundAlreadyProcessed, got: %v", err)
|
||||
assert.False(t, errors.Is(err, ErrRefundDeclined), "already-processed refund must not be classified as declined: %v", err)
|
||||
|
||||
client.mu.RLock()
|
||||
defer client.mu.RUnlock()
|
||||
assert.Len(t, client.refunds, 0, "no refund must be stored when the payment is already refunded")
|
||||
assert.Len(t, client.refundByKey, 0, "no refund-by-key entry must be stored when the payment is already refunded")
|
||||
}
|
||||
|
||||
func TestDevClient_RefundPayment_FailRefundCode_OtherCode(t *testing.T) {
|
||||
// Any other code configured via FailRefundCode preserves the prior
|
||||
// ErrRefundDeclined classification (e.g. REFUND_DECLINED in prod).
|
||||
client := NewDevClient().(*MockClient)
|
||||
client.FailRefundCode = "REFUND_DECLINED"
|
||||
|
||||
ctx := context.Background()
|
||||
req := RefundPaymentReq{
|
||||
PaymentID: "pay_mock_refund_declined",
|
||||
Amount: 5000,
|
||||
IdempotencyKey: "refund-key-declined",
|
||||
Reason: "declined",
|
||||
}
|
||||
|
||||
result, err := client.RefundPayment(ctx, req)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, ErrRefundDeclined), "expected ErrRefundDeclined, got: %v", err)
|
||||
assert.False(t, errors.Is(err, ErrRefundAlreadyProcessed), "declined refund must not be classified as already processed: %v", err)
|
||||
}
|
||||
|
||||
func TestDevClient_ConcurrentPayments(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
|
||||
@@ -402,6 +451,46 @@ func TestDevClient_CreatePayment_DedupsOnIdempotencyKey(t *testing.T) {
|
||||
assert.Equal(t, first.ID, byKey.ID)
|
||||
}
|
||||
|
||||
func TestDevClient_RefundPayment_DedupsOnIdempotencyKey(t *testing.T) {
|
||||
// Real Square dedups on idempotency key: a same-key retry returns the
|
||||
// original refund. The mock must mirror this or the pending-refund resume
|
||||
// path can't be exercised (and a retry could double-refund the customer).
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
paymentResult, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 10000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "payment-for-refund-dedup",
|
||||
ReferenceID: "booking-refund-dedup",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
req := RefundPaymentReq{
|
||||
PaymentID: paymentResult.ID,
|
||||
Amount: 5000,
|
||||
IdempotencyKey: "refund-dedup-key-1",
|
||||
Reason: "customer request",
|
||||
}
|
||||
|
||||
first, err := client.RefundPayment(ctx, req)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, first.ID)
|
||||
|
||||
second, err := client.RefundPayment(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, first.ID, second.ID, "same-key retry must return the original refund, not a new one")
|
||||
|
||||
// Only one refund stored in the mock's refunds map (deduped).
|
||||
client.mu.RLock()
|
||||
defer client.mu.RUnlock()
|
||||
assert.Len(t, client.refunds, 1, "same-key retry must not store a second refund")
|
||||
byKey := client.refundByKey["refund-dedup-key-1"]
|
||||
assert.NotNil(t, byKey)
|
||||
assert.Equal(t, first.ID, byKey.ID)
|
||||
}
|
||||
|
||||
func TestDevClient_CreatePayment_AutocompleteFalse(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
@@ -519,6 +608,75 @@ func TestDevClient_CreateCheckout_HoldCheckouts(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDevClient_ListPaymentRefunds_FiltersByPaymentAndTime(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
begin := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
client.mu.Lock()
|
||||
client.refunds["ref_1"] = &RefundResult{
|
||||
ID: "ref_1", Status: "COMPLETED", Amount: 5000, PaymentID: "pay_a",
|
||||
LocationID: "L_MOCK", Reason: "customer request", CreatedAt: begin.Add(2 * 24 * time.Hour).Format(time.RFC3339),
|
||||
}
|
||||
client.refunds["ref_2"] = &RefundResult{
|
||||
ID: "ref_2", Status: "COMPLETED", Amount: 2500, PaymentID: "pay_b",
|
||||
LocationID: "L_MOCK", Reason: "customer request", CreatedAt: begin.Add(3 * 24 * time.Hour).Format(time.RFC3339),
|
||||
}
|
||||
client.refunds["ref_3"] = &RefundResult{
|
||||
ID: "ref_3", Status: "COMPLETED", Amount: 1000, PaymentID: "pay_a",
|
||||
LocationID: "L_MOCK", Reason: "customer request", CreatedAt: begin.Add(-1 * 24 * time.Hour).Format(time.RFC3339),
|
||||
}
|
||||
client.mu.Unlock()
|
||||
|
||||
results, err := client.ListPaymentRefunds(ctx, "pay_a", begin)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 1, "only the pay_a refund created after beginTime must be returned")
|
||||
assert.Equal(t, "ref_1", results[0].ID)
|
||||
assert.Equal(t, int64(5000), results[0].Amount)
|
||||
assert.Equal(t, "COMPLETED", results[0].Status)
|
||||
assert.Equal(t, "pay_a", results[0].PaymentID)
|
||||
}
|
||||
|
||||
func TestDevClient_ListPaymentRefunds_AfterRefundPayment(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
paymentResult, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 10000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "payment-for-list-refunds",
|
||||
ReferenceID: "booking-list-refunds",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
refundResult, err := client.RefundPayment(ctx, RefundPaymentReq{
|
||||
PaymentID: paymentResult.ID,
|
||||
Amount: 5000,
|
||||
IdempotencyKey: "refund-for-list",
|
||||
Reason: "customer request",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
results, err := client.ListPaymentRefunds(ctx, paymentResult.ID, time.Now().Add(-24*time.Hour))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 1, "the refund stored by RefundPayment must be listed")
|
||||
assert.Equal(t, refundResult.ID, results[0].ID)
|
||||
assert.Equal(t, int64(5000), results[0].Amount)
|
||||
assert.Equal(t, "COMPLETED", results[0].Status)
|
||||
assert.Equal(t, paymentResult.ID, results[0].PaymentID)
|
||||
}
|
||||
|
||||
func TestDevClient_ListPaymentRefunds_Empty(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
results, err := client.ListPaymentRefunds(ctx, "pay_unknown", time.Now().Add(-24*time.Hour))
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, results, "must return an empty slice, not nil")
|
||||
assert.Empty(t, results)
|
||||
}
|
||||
|
||||
func TestDetectCardInfo_Variants(t *testing.T) {
|
||||
tests := []struct {
|
||||
sourceID string
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
package square
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrRefundDeclined is returned by RefundPayment when Square definitively
|
||||
// rejects a refund (refund declined, refund amount exceeds the original
|
||||
// charge, invalid payment ID, etc.). Callers use errors.Is to distinguish a
|
||||
// definitive business rejection — where the refund record should be marked
|
||||
// 'failed' and never retried — from an ambiguous transport/5xx error that is
|
||||
// safe to retry later. Note: PAYMENT_ALREADY_REFUNDED is NOT a decline — the
|
||||
// money has already moved, so it maps to ErrRefundAlreadyProcessed instead.
|
||||
var ErrRefundDeclined = errors.New("square: refund declined")
|
||||
|
||||
// ErrRefundAlreadyProcessed is returned by RefundPayment when Square reports
|
||||
// PAYMENT_ALREADY_REFUNDED — the payment is already fully refunded at Square,
|
||||
// so the money has already moved. Callers resolve the refund record to
|
||||
// 'completed' rather than 'failed' (which would let the guard over-refund).
|
||||
var ErrRefundAlreadyProcessed = errors.New("square: refund already processed")
|
||||
|
||||
// CreatePaymentReq maps to Square's CreatePayment endpoint (POST /v2/payments).
|
||||
// Square API reference: https://developer.squareup.com/reference/square/payments-api/create-payment
|
||||
@@ -139,4 +158,11 @@ type SquareClient interface {
|
||||
CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error)
|
||||
GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error)
|
||||
DeleteCardOnFile(ctx context.Context, cardID string) error
|
||||
|
||||
// ListPaymentRefunds returns the refunds Square has recorded for a payment
|
||||
// (charge), created at or after beginTime. Used to reconcile pending refund
|
||||
// rows against Square before marking them failed (money may already have
|
||||
// moved). Square's endpoint lists account-wide; the caller filters by
|
||||
// PaymentID client-side.
|
||||
ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user