fix: payments review rounds — money-safety, GDPR, security, gift-card cancel, modal stacking

Money-safety:
- Deterministic till idempotency fallback (Square-charging only); cash/on_the_house keep unique keys; £250 till gift-card cap; 45-char key validation
- Gift-card admin caps £250/tx + £5,000/day; user buy £500/day; BuyGiftCard allowlist unchanged
- CancelGiftCard: CCR 2013 14-day right with partial-spend refund of the unspent balance (spend verified via payments.gift_card_id); atomic vs redeem/transfer; refunds stay pending until reversal commits; admin cancel surface (AdminCancelGiftCard)
- Sweep: cancelled-booking charges failed+notified instead of silently completed; source-override replay uses live square_source_id; legacy square-less refund sweep; snapshot refresh on pending reuse
- Refund lock consolidation; recordTerminalPaymentTx shared recorder; structured Square error codes; terminal checkout CustomerID

GDPR / security:
- Notes retained as de-identified medical/safety record at erasure (single field treated as health data; rest of record wiped, no re-identification map) + comments updated per UK GDPR/Art 9/Equality Act 2010
- square_request_snapshot PII scrubbed on all erasure paths; delete_guest_user FK unlinks; verification codes + dispute reasons handled; idle/stale-guest erasure deletes Square cards/customers + CardDAV/R2
- Durable square-erasure outbox job (retry-square-erasures); 2FA dev/prod build split, pepper fail-closed, no prod code-in-log; prod 2FA delivery fail-loud without a channel
- Webhook unknown-type family split (non-money acked, money retried); untracked dispute notifications; rate-limit CF/X-Real-IP trust gating; nginx CSP nonce + api_limit

Frontend:
- Dynamic z-index stack (ui/dialog/zindex.ts) claimed in open order via data-state observer; re-claims on every reopen; removes stale !z-* overrides — nested modals (booking→user→booking) always paint newest-on-top (browser-verified 3-level + reopen)
- Mobile: iOS zoom fixes, bottom-sheet dialogs, 44px touch targets, inputmode decimal, dvh
- Gift-card buy/cancel UI, admin £250 + daily limits, cancellation/privacy/terms policy accuracy

S3:
- Connect() creates buckets before probing; in-memory fallback only on genuine unreachability; health reports degraded; stale S3_PUBLIC_URL documented (host-specific)

Tests/docs:
- 2263 test functions; all 22 backend packages green; round8/9/10 regression suites; NextEditWindowTime removes wall-clock flake; docs reconciled (notes retention, gift-card partial-use, modal T15 future work)
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 9111258461
commit 78e6d00dc5
89 changed files with 7702 additions and 853 deletions
+200
View File
@@ -2,6 +2,7 @@ package jobs
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
@@ -14,6 +15,7 @@ import (
"crussell/handlers/payments"
"crussell/handlers/scheduling"
"crussell/handlers/user"
"crussell/internal/square"
"crussell/mw"
"github.com/jackc/pgx/v5"
@@ -247,6 +249,22 @@ func RegisterAll(s *Scheduler) {
Concurrency: 1,
Handler: ScanCriticalPaymentLogs,
})
// Durable safety net for the GDPR account-deletion Square outbox (Fault
// A1): retries the Square card/customer deletions that the
// DeleteAccountHandler async cleanup could not finish (process crash or
// exhausted retries), using the outbox rows the handler persisted inside
// its anonymization tx before it committed. Hourly — the async goroutine
// handles the common case within seconds, so this only catches stragglers.
// The schedule is deliberately unshared so a long run cannot contend with
// the payment sweeps.
s.Register(Job{
Name: "retry-square-erasures",
Schedule: "17 * * * *", // Hourly at :17
Timeout: 30 * time.Second,
Concurrency: 1,
Handler: RetryPendingSquareErasures,
})
}
// SweepSquareWebhookEvents deletes square_webhook_events rows older than 90
@@ -347,3 +365,185 @@ func ScanCriticalPaymentLogs(ctx context.Context) (int, error) {
}
return n, nil
}
// erasureNotificationKey derives the notification-dedup key for a pending
// outbox row: the user id when the row still carries one (registered users
// whose deletion tx has not yet run), otherwise a stable row-scoped key (guest
// rows are unlinked by delete_guest_user and the account-deletion outbox
// NULLs user_id on the rows it writes).
func erasureNotificationKey(userID sql.NullString, rowID string) string {
if userID.Valid && userID.String != "" {
return userID.String
}
return "row:" + rowID
}
// raiseErasureNotification raises the deduped critical notification once per
// key. Repeated failures across job runs collapse to a single alert (the
// deterministic admin_notifications id in
// user.InsertSquareErasureCriticalNotification does the ON CONFLICT dedup).
func raiseErasureNotification(ctx context.Context, key string, notified map[string]bool) {
if notified[key] {
return
}
notified[key] = true
user.InsertSquareErasureCriticalNotification(ctx, key)
}
// RetryPendingSquareErasures is the durable safety net for the account-deletion
// Square outbox (Fault A1). DeleteAccountHandler persists the Square
// card/customer erasure targets on the scrubbed, soft-deleted user_saved_cards
// rows (last_4 = 'XXXX') inside its anonymization transaction, before it
// commits. If the process crashes between that commit and the async cleanup
// goroutine finishing, those rows are the only remaining record of the
// Square-side PII — this job finds them and retries the Square deletion so the
// card/customer is never permanently orphaned at Square. On success (or a
// Square NOT_FOUND — the data is already gone) it drains the outbox columns; on
// final failure it raises a critical payment notification, deduped per affected
// user/row. Returns the number of outbox rows drained.
func RetryPendingSquareErasures(ctx context.Context) (int, error) {
if payments.SquareClient == nil {
// Square not configured: no external erasure is possible, and the
// handler only writes outbox entries when a client was configured.
return 0, nil
}
client := payments.SquareClient
rows, err := db.Conn.Query(ctx, `
SELECT id, user_id, square_card_id, square_customer_id
FROM user_saved_cards
WHERE deleted_at IS NOT NULL
AND last_4 = 'XXXX'
AND (square_card_id IS NOT NULL OR square_customer_id IS NOT NULL)
ORDER BY id
`)
if err != nil {
return 0, fmt.Errorf("failed to query pending Square erasures: %w", err)
}
defer rows.Close()
type pendingErasure struct {
rowID string
userID sql.NullString
cardID sql.NullString
customerID sql.NullString
}
var pending []pendingErasure
for rows.Next() {
var p pendingErasure
if err := rows.Scan(&p.rowID, &p.userID, &p.cardID, &p.customerID); err != nil {
return 0, fmt.Errorf("failed to scan pending Square erasure: %w", err)
}
pending = append(pending, p)
}
if err := rows.Err(); err != nil {
return 0, fmt.Errorf("failed to iterate pending Square erasures: %w", err)
}
if len(pending) == 0 {
return 0, nil
}
// Group the outbox rows: each card id maps to exactly one row (per-user
// UNIQUE), each customer id may map to several rows (shared across the
// user's saved cards) and may also appear on other deleted accounts' rows.
cardRows := map[string][]pendingErasure{}
customerRows := map[string][]pendingErasure{}
for _, p := range pending {
if p.cardID.Valid && p.cardID.String != "" {
cardRows[p.cardID.String] = append(cardRows[p.cardID.String], p)
}
if p.customerID.Valid && p.customerID.String != "" {
customerRows[p.customerID.String] = append(customerRows[p.customerID.String], p)
}
}
notified := map[string]bool{}
drained := map[string]bool{}
// Cards: each ccof: token is erased once. NOT_FOUND means Square no longer
// has the card — the erasure is complete, so the outbox is drained rather
// than alerted on.
for cardID, cardPend := range cardRows {
rowID := cardPend[0].rowID
err := user.RetrySquareDeletion(ctx, func(actx context.Context) error {
return client.DeleteCardOnFile(actx, cardID)
})
if err != nil && !square.IsNotFound(err) {
raiseErasureNotification(ctx, erasureNotificationKey(cardPend[0].userID, rowID), notified)
log.Printf("Error: retry-square-erasures failed to delete Square card %s (outbox row %s): %v", square.TokenPrefix(cardID), rowID, err)
slog.Error("square card erasure retry failed after attempts", "row", rowID, "card", square.TokenPrefix(cardID), "error", err)
continue
}
if _, err := db.Conn.Exec(ctx, `
UPDATE user_saved_cards SET square_card_id = NULL
WHERE id = $1 AND deleted_at IS NOT NULL
`, rowID); err != nil {
return 0, fmt.Errorf("failed to clear card erasure outbox row %s: %w", rowID, err)
}
drained[rowID] = true
}
// Customers: one DeleteCustomer per distinct id, guarded by the
// still-referenced-by-another-account check (a shared Square customer must
// survive while any active card of another account references it).
for customerID, custPend := range customerRows {
stillReferenced := false
for _, p := range custPend {
var ref bool
if err := db.Conn.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM user_saved_cards
WHERE square_customer_id = $1 AND deleted_at IS NULL
AND user_id IS DISTINCT FROM $2
)
`, customerID, p.userID).Scan(&ref); err != nil {
return 0, fmt.Errorf("failed to check Square customer %s references before deletion: %w", square.TokenPrefix(customerID), err)
}
if ref {
stillReferenced = true
break
}
}
if stillReferenced {
// Deliberately kept (shared customer) — not a pending erasure.
// Drain the outbox rows so the job stops retrying a deletion that
// must not happen; the customer is erased when the last referencing
// account is itself erased.
for _, p := range custPend {
if _, err := db.Conn.Exec(ctx, `
UPDATE user_saved_cards SET square_customer_id = NULL
WHERE id = $1 AND deleted_at IS NOT NULL
`, p.rowID); err != nil {
return 0, fmt.Errorf("failed to clear skipped customer erasure outbox row %s: %w", p.rowID, err)
}
drained[p.rowID] = true
}
continue
}
err := user.RetrySquareDeletion(ctx, func(actx context.Context) error {
return client.DeleteCustomer(actx, customerID)
})
if err != nil && !square.IsNotFound(err) {
for _, p := range custPend {
raiseErasureNotification(ctx, erasureNotificationKey(p.userID, p.rowID), notified)
}
log.Printf("Error: retry-square-erasures failed to delete Square customer %s (%d outbox rows): %v", square.TokenPrefix(customerID), len(custPend), err)
slog.Error("square customer erasure retry failed after attempts", "customer", square.TokenPrefix(customerID), "rows", len(custPend), "error", err)
continue
}
for _, p := range custPend {
if _, err := db.Conn.Exec(ctx, `
UPDATE user_saved_cards SET square_customer_id = NULL
WHERE id = $1 AND deleted_at IS NOT NULL
`, p.rowID); err != nil {
return 0, fmt.Errorf("failed to clear customer erasure outbox row %s: %w", p.rowID, err)
}
drained[p.rowID] = true
}
}
if n := len(drained); n > 0 {
log.Printf("[ERASURE] retry-square-erasures drained %d pending Square erasure outbox row(s)", n)
}
return len(drained), nil
}
+3 -2
View File
@@ -413,8 +413,8 @@ func TestRegisterAll_RegistersExpectedJobs(t *testing.T) {
s := New()
RegisterAll(s)
if got := len(s.registry); got != 25 {
t.Fatalf("RegisterAll() registered %d jobs, want 25", got)
if got := len(s.registry); got != 26 {
t.Fatalf("RegisterAll() registered %d jobs, want 26", got)
}
registered := make(map[string]Job, len(s.registry))
@@ -493,6 +493,7 @@ func expectedJobNames() map[string]bool {
"sweep-square-webhook-events": true,
"apply-default-hours": true,
"scan-critical-payment-logs": true,
"retry-square-erasures": true,
}
}
+5
View File
@@ -12,6 +12,11 @@ import (
var Client Uploader
// FallbackToInMemory mirrors the dev-build flag so main.go can reference it
// in non-dev builds. It is always false here: the production/R2 build never
// uses the in-memory fallback client.
var FallbackToInMemory bool
type Uploader interface {
Upload(ctx context.Context, bucket, key string, body io.Reader, contentType string) error
Download(ctx context.Context, bucket, key string, w io.Writer) error
+66 -21
View File
@@ -4,6 +4,7 @@ package s3
import (
"context"
"errors"
"fmt"
"io"
"log"
@@ -14,10 +15,17 @@ import (
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/smithy-go"
)
var Client Uploader
// FallbackToInMemory reports whether the active S3 client is the in-memory
// fallback (placeholder cdn.example.com URLs, data lost on restart). It is
// declared here (dev build) and in s3.go (prod build, always false) so
// main.go can surface it from the health endpoint in both build variants.
var FallbackToInMemory bool
type Uploader interface {
Upload(ctx context.Context, bucket, key string, body io.Reader, contentType string) error
Download(ctx context.Context, bucket, key string, w io.Writer) error
@@ -79,7 +87,27 @@ func (m *inMemS3) HealthCheck(_ context.Context) error {
return nil
}
// isMissingBucket reports whether err is an S3 API error indicating the
// bucket itself does not exist (404 NoSuchBucket / NotFound) rather than a
// connectivity failure. The SDK v2 surfaces HeadBucket 404s as smithy.APIError
// implementations (*types.NotFound / *types.NoSuchBucket, or a generic API
// error); connection-level failures (refused, timeout, DNS, AccessDenied) do
// not implement smithy.APIError and return false.
func isMissingBucket(err error) bool {
var apiErr smithy.APIError
if !errors.As(err, &apiErr) {
return false
}
switch apiErr.ErrorCode() {
case "NoSuchBucket", "NotFound":
return true
default:
return false
}
}
func Connect() error {
FallbackToInMemory = false
// Check for RUSTFS_* vars first (matching compose.yml), fall back to S3_* vars
endpoint := os.Getenv("RUSTFS_ENDPOINT")
if endpoint == "" {
@@ -139,23 +167,55 @@ func Connect() error {
)
if err != nil {
log.Printf("S3: AWS config failed (%v) — falling back to in-memory S3", err)
log.Printf("S3 WARNING: portfolio/photo uploads will use in-memory storage with placeholder URLs (cdn.example.com) and data is LOST on restart")
FallbackToInMemory = true
Client = &inMemS3{}
return nil
}
// Attempt RUSTFS/S3 connection; fall back to in-memory on any failure.
// Attempt RUSTFS/S3 connection; fall back to in-memory on genuine
// server-unreachability only.
ctx := context.Background()
s3Raw := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(endpoint)
o.UsePathStyle = true
})
// Verify connectivity with a HeadBucket call before committing.
// Create the primary bucket before probing: a fresh data volume has no
// bucket yet, so a pre-probe HeadBucket 404 must not be mistaken for an
// unreachable server. Creation errors are non-fatal.
_, err = s3Raw.CreateBucket(ctx, &s3.CreateBucketInput{
Bucket: aws.String(bucket),
})
if err != nil {
log.Printf("Bucket creation: %v (may already exist)", err)
}
// Create the profile-pics bucket first too, so a fresh volume gets both
// buckets before the connectivity probe runs.
if profilePicsBucket != bucket {
_, err = s3Raw.CreateBucket(ctx, &s3.CreateBucketInput{
Bucket: aws.String(profilePicsBucket),
})
if err != nil {
log.Printf("Profile pics bucket creation: %v (may already exist)", err)
}
}
// Connectivity probe. A missing bucket (404 NoSuchBucket/NotFound) must
// never trigger the in-memory fallback — it would persist placeholder
// URLs; only connection-level failures fall back.
_, err = s3Raw.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(bucket)})
if err != nil {
log.Printf("S3: RUSTFS not reachable at %s (%v) — falling back to in-memory S3", endpoint, err)
Client = &inMemS3{}
return nil
if isMissingBucket(err) {
log.Printf("S3: bucket %q still missing after CreateBucket (%v) — keeping the real client; uploads may fail until the bucket exists", bucket, err)
} else {
log.Printf("S3: RUSTFS not reachable at %s (%v) — falling back to in-memory S3", endpoint, err)
log.Printf("S3 WARNING: portfolio/photo uploads will use in-memory storage with placeholder URLs (cdn.example.com) and data is LOST on restart")
FallbackToInMemory = true
Client = &inMemS3{}
return nil
}
}
Client = &S3Client{
@@ -164,14 +224,6 @@ func Connect() error {
publicURL: publicURL,
}
// Create bucket if it doesn't exist
_, err = s3Raw.CreateBucket(ctx, &s3.CreateBucketInput{
Bucket: aws.String(bucket),
})
if err != nil {
log.Printf("Bucket creation: %v (may already exist)", err)
}
// Set bucket policy for public read access
policy := fmt.Sprintf(`{
"Version": "2012-10-17",
@@ -191,15 +243,8 @@ func Connect() error {
log.Printf("Bucket policy: %v (may already exist)", err)
}
// Create profile pics bucket if it doesn't exist
// Profile pics bucket policy
if profilePicsBucket != bucket {
_, err = s3Raw.CreateBucket(ctx, &s3.CreateBucketInput{
Bucket: aws.String(profilePicsBucket),
})
if err != nil {
log.Printf("Profile pics bucket creation: %v (may already exist)", err)
}
profilePolicy := fmt.Sprintf(`{
"Version": "2012-10-17",
"Statement": [{
+61
View File
@@ -5,10 +5,15 @@ package s3
import (
"bytes"
"context"
"fmt"
"io"
"net"
"strings"
"sync"
"testing"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -154,3 +159,59 @@ func TestConnect_FallbackToInMemory(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestIsMissingBucket_NoSuchBucketGeneric(t *testing.T) {
t.Parallel()
err := &smithy.GenericAPIError{Code: "NoSuchBucket", Message: "bucket does not exist"}
assert.True(t, isMissingBucket(err))
}
func TestIsMissingBucket_NotFoundGeneric(t *testing.T) {
t.Parallel()
err := &smithy.GenericAPIError{Code: "NotFound", Message: "not found"}
assert.True(t, isMissingBucket(err))
}
func TestIsMissingBucket_NotFoundTyped(t *testing.T) {
t.Parallel()
// The concrete error types the vendored SDK surfaces for HeadBucket 404s.
assert.True(t, isMissingBucket(&types.NotFound{}))
assert.True(t, isMissingBucket(&types.NoSuchBucket{}))
}
func TestIsMissingBucket_NotFoundViaOperationError(t *testing.T) {
t.Parallel()
// The SDK wraps API errors in an OperationError; errors.As must still find it.
err := &smithy.OperationError{ServiceID: "S3", OperationName: "HeadBucket", Err: &types.NotFound{}}
assert.True(t, isMissingBucket(err))
}
func TestIsMissingBucket_NotFoundWrapped(t *testing.T) {
t.Parallel()
err := fmt.Errorf("head bucket: %w", &smithy.GenericAPIError{Code: "NoSuchBucket", Message: "x"})
assert.True(t, isMissingBucket(err))
}
func TestIsMissingBucket_ConnectionRefused(t *testing.T) {
t.Parallel()
err := &net.OpError{Op: "dial", Net: "tcp", Err: fmt.Errorf("connect: connection refused")}
assert.False(t, isMissingBucket(err))
}
func TestIsMissingBucket_EOF(t *testing.T) {
t.Parallel()
assert.False(t, isMissingBucket(io.EOF))
}
func TestIsMissingBucket_AccessDenied(t *testing.T) {
t.Parallel()
// Reachable but unauthorized is a connectivity/credentials problem, not a
// missing bucket — it must still trigger the fallback.
err := &types.AccessDenied{}
assert.False(t, isMissingBucket(err))
}
func TestIsMissingBucket_Nil(t *testing.T) {
t.Parallel()
assert.False(t, isMissingBucket(nil))
}
+16 -8
View File
@@ -275,10 +275,14 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
// Square requires customer_id when charging a card-on-file (ccof:) token.
// The mock enforces the same rule so dev parity catches the production bug
// where a saved-card charge is sent without the customer's Square customer
// id (real Square rejects it with a 400 INVALID_REQUEST_ERROR).
// id (real Square rejects it with a 400 MISSING_REQUIRED_PARAMETER —
// category INVALID_REQUEST_ERROR — because customer_id is required for a
// card-on-file source).
if strings.HasPrefix(req.SourceID, "ccof:") && req.CustomerID == "" {
return nil, &squareAPIError{
Code: "INVALID_REQUEST_ERROR",
Code: "MISSING_REQUIRED_PARAMETER",
Category: "INVALID_REQUEST_ERROR",
Field: "customer_id",
Detail: "customer_id required for card-on-file source",
StatusCode: http.StatusBadRequest,
err: errors.New("square: customer_id required for card-on-file source"),
@@ -444,9 +448,10 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
}
if deviceID == "" {
return nil, &squareAPIError{
Code: "INVALID_REQUEST_ERROR",
Code: "MISSING_REQUIRED_PARAMETER",
Detail: "device_options.device_id is required to create a terminal checkout",
Category: "INVALID_REQUEST_ERROR",
Field: "device_options.device_id",
StatusCode: http.StatusBadRequest,
err: errors.New("square: device_options.device_id is required for a terminal checkout (set SQUARE_TERMINAL_DEVICE_ID or pass DeviceID)"),
}
@@ -699,8 +704,8 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
refundID := fmt.Sprintf("ref_mock_%d", now.UnixNano())
// Square's RefundPayment requires amount_money — a missing or zero amount
// is rejected (400 INVALID_REQUEST_ERROR / REFUND_AMOUNT_INVALID), never
// treated as a "full refund" shortcut. The mock mirrors this so a
// is rejected (400 REFUND_AMOUNT_INVALID, category INVALID_REQUEST_ERROR),
// never treated as a "full refund" shortcut. The mock mirrors this so a
// missing-amount bug can't be masked in dev (the real DB also has a CHECK
// amount > 0, so a £0 refund must fail rather than silently record nothing).
if req.Amount <= 0 {
@@ -780,11 +785,14 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, cu
// runtime (confirmed by Square's own SDK maintainer). The production client
// omits an empty customer_id via omitempty and every production caller
// provisions a Square customer first, so the gate is enforced upstream — the
// mock must mirror it (same structured INVALID_REQUEST_ERROR as the ccof:
// CreatePayment gate above) so sandbox/dev tests exercise the same rejection.
// mock must mirror it (same structured MISSING_REQUIRED_PARAMETER as the
// ccof: CreatePayment gate above) so sandbox/dev tests exercise the same
// rejection.
if customerID == "" {
return nil, &squareAPIError{
Code: "INVALID_REQUEST_ERROR",
Code: "MISSING_REQUIRED_PARAMETER",
Category: "INVALID_REQUEST_ERROR",
Field: "card.customer_id",
Detail: "customer_id is required to create a card on file",
StatusCode: http.StatusBadRequest,
err: errors.New("square: customer_id is required to create a card on file"),
+3 -3
View File
@@ -335,7 +335,7 @@ func TestDevClient_CreateCardOnFile_RequiresCustomerID(t *testing.T) {
_, err := client.CreateCardOnFile(ctx, "user-no-customer", "cnon:test-token", "")
require.Error(t, err, "card creation without customer_id must be rejected")
assert.Equal(t, "INVALID_REQUEST_ERROR", ErrorCode(err))
assert.Equal(t, "MISSING_REQUIRED_PARAMETER", ErrorCode(err))
assert.Contains(t, ErrorDetail(err), "customer_id")
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
@@ -1360,7 +1360,7 @@ func TestDevClient_CreatePayment_CardOnFileRequiresCustomerID(t *testing.T) {
ReferenceID: "booking-ccof-no-customer",
})
require.Error(t, err, "ccof charge without customer_id must be rejected")
assert.Equal(t, "INVALID_REQUEST_ERROR", ErrorCode(err))
assert.Equal(t, "MISSING_REQUIRED_PARAMETER", ErrorCode(err))
assert.Contains(t, ErrorDetail(err), "customer_id required")
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
@@ -1838,7 +1838,7 @@ func TestDevClient_CreateCheckout_RequiresDeviceID(t *testing.T) {
})
require.Error(t, err)
assert.Nil(t, res)
assert.Equal(t, "INVALID_REQUEST_ERROR", ErrorCode(err))
assert.Equal(t, "MISSING_REQUIRED_PARAMETER", ErrorCode(err))
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
})
+2 -2
View File
@@ -90,7 +90,7 @@ type RefundPaymentReq struct {
// Reference: https://developer.squareup.com/reference/square/objects/Payment
type PaymentResult struct {
ID string // Square payment ID (e.g. "pay_xxx")
Status string // "APPROVED", "COMPLETED", "FAILED", "CANCELED"
Status string // "APPROVED", "PENDING", "COMPLETED", "CANCELED", "FAILED"
Amount int64 // total amount charged in pence (including tip)
CardBrand string // "VISA", "MASTERCARD", "AMERICAN_EXPRESS", "DISCOVER", etc.
CardLast4 string
@@ -156,7 +156,7 @@ type CardOnFile struct {
// Reference: https://developer.squareup.com/reference/square/objects/Refund
type RefundResult struct {
ID string // Square refund ID (e.g. "ref_xxx")
Status string // "PENDING", "COMPLETED", "FAILED"
Status string // "PENDING", "COMPLETED", "REJECTED", "FAILED"
Amount int64 // refund amount in pence
PaymentID string // original payment being refunded
LocationID string // location where refund was processed