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
197 lines
4.9 KiB
Go
197 lines
4.9 KiB
Go
package jobs
|
|
|
|
import (
|
|
"time"
|
|
|
|
"crussell/auth"
|
|
authHandlers "crussell/handlers/auth"
|
|
"crussell/handlers/payments"
|
|
"crussell/handlers/scheduling"
|
|
"crussell/handlers/user"
|
|
"crussell/mw"
|
|
)
|
|
|
|
// RegisterAll registers every background maintenance job on the scheduler.
|
|
// Call once during server startup, before s.Start().
|
|
func RegisterAll(s *Scheduler) {
|
|
// === HIGH FREQUENCY — every 5 minutes ===
|
|
|
|
s.Register(Job{
|
|
Name: "cleanup-reservations",
|
|
Schedule: "*/5 * * * *",
|
|
Timeout: 30 * time.Second,
|
|
Concurrency: 1,
|
|
Handler: scheduling.CleanupOldReservations,
|
|
})
|
|
|
|
s.Register(Job{
|
|
Name: "cleanup-expired-deposits",
|
|
Schedule: "*/5 * * * *",
|
|
Timeout: 30 * time.Second,
|
|
Concurrency: 1,
|
|
Handler: scheduling.CleanupExpiredDeposits,
|
|
})
|
|
|
|
s.Register(Job{
|
|
Name: "cleanup-rate-limiters",
|
|
Schedule: "*/5 * * * *",
|
|
Timeout: 30 * time.Second,
|
|
Concurrency: 1,
|
|
Handler: mw.CleanupAllRateLimiters,
|
|
})
|
|
|
|
s.Register(Job{
|
|
Name: "cleanup-gdpr-export-cache",
|
|
Schedule: "*/5 * * * *",
|
|
Timeout: 10 * time.Second,
|
|
Concurrency: 1,
|
|
Handler: user.CleanupGDPRExportCache,
|
|
})
|
|
|
|
s.Register(Job{
|
|
Name: "sweep-pending-square-refunds",
|
|
Schedule: "*/5 * * * *",
|
|
Timeout: 60 * time.Second,
|
|
Concurrency: 1,
|
|
Handler: payments.SweepPendingSquareRefunds,
|
|
})
|
|
|
|
// === MID FREQUENCY — every minute (progressive rate limiter was on 30s) ===
|
|
|
|
s.Register(Job{
|
|
Name: "cleanup-progressive-rate-limiter",
|
|
Schedule: "* * * * *",
|
|
Timeout: 10 * time.Second,
|
|
Concurrency: 1,
|
|
Handler: mw.CleanupProgressiveRateLimiter,
|
|
})
|
|
|
|
// === MID FREQUENCY — hourly ===
|
|
|
|
s.Register(Job{
|
|
Name: "cleanup-expired-loyalty-redemptions",
|
|
Schedule: "0 * * * *",
|
|
Timeout: 30 * time.Second,
|
|
Concurrency: 1,
|
|
Handler: scheduling.CleanupExpiredLoyaltyRedemptions,
|
|
})
|
|
|
|
s.Register(Job{
|
|
Name: "cleanup-old-idempotency-keys",
|
|
Schedule: "0 * * * *",
|
|
Timeout: 30 * time.Second,
|
|
Concurrency: 1,
|
|
Handler: scheduling.CleanupOldIdempotencyKeys,
|
|
})
|
|
|
|
s.Register(Job{
|
|
Name: "cleanup-revoked-jtis",
|
|
Schedule: "0 * * * *",
|
|
Timeout: 30 * time.Second,
|
|
Concurrency: 1,
|
|
Handler: auth.CleanupRevokedJTIs,
|
|
})
|
|
|
|
s.Register(Job{
|
|
Name: "cleanup-stale-login-entries",
|
|
Schedule: "0 * * * *",
|
|
Timeout: 30 * time.Second,
|
|
Concurrency: 1,
|
|
Handler: authHandlers.CleanupStaleLoginEntries,
|
|
})
|
|
|
|
// === LOW FREQUENCY — daily, off-peak (staggered to avoid DB contention) ===
|
|
|
|
s.Register(Job{
|
|
Name: "anonymize-stale-guest-accounts",
|
|
Schedule: "0 3 * * *",
|
|
Timeout: 5 * time.Minute,
|
|
Concurrency: 1,
|
|
Handler: scheduling.AnonymizeStaleGuestAccounts,
|
|
})
|
|
|
|
s.Register(Job{
|
|
Name: "cleanup-expired-financial-records",
|
|
Schedule: "0 4 * * *",
|
|
Timeout: 10 * time.Minute,
|
|
Concurrency: 1,
|
|
Handler: scheduling.CleanupExpiredFinancialRecords,
|
|
})
|
|
|
|
s.Register(Job{
|
|
Name: "cleanup-idle-accounts",
|
|
Schedule: "30 3 * * *",
|
|
Timeout: 5 * time.Minute,
|
|
Concurrency: 1,
|
|
Handler: scheduling.CleanupIdleAccounts,
|
|
})
|
|
|
|
s.Register(Job{
|
|
Name: "cleanup-expired-gift-cards",
|
|
Schedule: "0 5 * * *",
|
|
Timeout: 5 * time.Minute,
|
|
Concurrency: 1,
|
|
Handler: scheduling.CleanupExpiredGiftCards,
|
|
})
|
|
|
|
s.Register(Job{
|
|
Name: "cleanup-old-name-history",
|
|
Schedule: "30 4 * * *",
|
|
Timeout: 30 * time.Second,
|
|
Concurrency: 1,
|
|
Handler: scheduling.CleanupOldNameHistory,
|
|
})
|
|
|
|
// === STAGED HOURS CHANGE ===
|
|
|
|
s.Register(Job{
|
|
Name: "apply-default-hours",
|
|
Schedule: "5 0 * * *", // Daily at 00:05 — after midnight to avoid race
|
|
Timeout: 30 * time.Second,
|
|
Concurrency: 1,
|
|
Handler: scheduling.ApplyScheduledDefaultHours,
|
|
})
|
|
|
|
// === BUSINESS LOGIC JOBS ===
|
|
|
|
s.Register(Job{
|
|
Name: "notify-unpaid-1-week",
|
|
Schedule: "0 7 * * *", // Daily at 7am — end of business day + 7 days
|
|
Timeout: 2 * time.Minute,
|
|
Concurrency: 1,
|
|
Handler: scheduling.NotifyUnpaidOneWeek,
|
|
})
|
|
|
|
s.Register(Job{
|
|
Name: "notify-unpaid-1-month",
|
|
Schedule: "30 7 * * *", // Daily at 7:30am (staggered from notify-unpaid-1-week)
|
|
Timeout: 2 * time.Minute,
|
|
Concurrency: 1,
|
|
Handler: scheduling.NotifyUnpaidOneMonth,
|
|
})
|
|
|
|
s.Register(Job{
|
|
Name: "transition-discount-campaigns",
|
|
Schedule: "0 * * * *", // Hourly
|
|
Timeout: 30 * time.Second,
|
|
Concurrency: 1,
|
|
Handler: scheduling.TransitionDiscountCampaigns,
|
|
})
|
|
|
|
s.Register(Job{
|
|
Name: "cleanup-verification-codes",
|
|
Schedule: "0 2 * * *", // Daily at 2am
|
|
Timeout: 30 * time.Second,
|
|
Concurrency: 1,
|
|
Handler: scheduling.CleanupExpiredVerificationCodes,
|
|
})
|
|
|
|
s.Register(Job{
|
|
Name: "cleanup-refresh-tokens",
|
|
Schedule: "0 2 * * *", // Daily at 2am
|
|
Timeout: 30 * time.Second,
|
|
Concurrency: 1,
|
|
Handler: scheduling.CleanupExpiredRefreshTokens,
|
|
})
|
|
}
|