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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [{
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user