Implement full Square payment review fixes + frontend polish

Implement every finding from the deep payment review (P0-P2, minors,
nitpicks), then close the post-implementation re-review items, then
align card-form typography and roll out the Square trust badge.

Backend - Square API alignment:
- tip_settings.allow_tipping nested under device_options (was top-level:
  terminal tips were silently lost in prod)
- CreateCardOnFile now accepts customerID and sends card.customer_id;
  saved-card (ccof:) charges forward square_customer_id as CustomerID
- New SquareClient methods GetPayment, CreateCustomer, CancelCheckout
- SCA verification_token accepted + forwarded in all charge paths
- ExpMonth/ExpYear -> *int; URL-path id validation; CancelCheckout
  NOT_FOUND-only no-op (dropped unverified NOOP); exported ErrorCode/
  ErrorDetail helpers; mock rejects raw PANs, RList locks, redacts
  emails, ForceRefundPending hook

Backend - money safety:
- sweepManualPendingSquareRefunds reconciles rows WITH square_refund_id
  instead of stranding them forever
- SweepStalePendingPayments reconciles at Square before failing (tri-state:
  leave pending on transport error, rescue completed, fail definitively)
- GetCheckoutStatus cancellation-recheck; terminal CANCELED resolution;
  SweepStaleTerminalCheckouts covers terminal_checkouts table
- till gift-card clawback on definitive failure incl. retry path +
  INSUFFICIENT_FUNDS/ADDRESS_VERIFICATION_FAILURE/TRANSACTION_LIMIT
- cross-user saved-card collision fixed (UNIQUE(user_id,square_card_id))
- customer provisioning (lazy, save-only); one-off/guest mint no customer
- discount preview/apply unified in discounts.go (global-milestone visible
  in preview, N+1 eliminated, redemption counter preserved on failures)
- webhook event_id dedup; refund loop dedup; stale comment fixes
- test-isolation t.Cleanup on committed sweep tests

Frontend:
- SCA tokenizeWithVerification across all charge flows (amount as
  major-units decimal), 5-min token-expiry re-tokenize, verification_token
  in request bodies
- PaymentModal synchronous double-click + zero/negative-amount guards
- till online-card UI wired to /api/admin/till/sale
- policyPopover generalised; new /privacy-policy route; consent checkbox
  copy + Square privacy link
- Square card iframe styled to app typography (Inter 14px, oklch tokens);
  mock form md:text-sm parity
- 'Secure payment powered by Square' badge on all 8 card-payment flows

Schema/docs: terminal_checkouts + square_customer_id + per-user card
constraint in init-script.sql; README migrations; P14 plan + backlog +
Technical Manual updated.

Includes 39 modified/new test files; full backend suite (25 pkgs),
-race on payments+square, and frontend build are green.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent fb21538532
commit 54a5b1024e
45 changed files with 6815 additions and 937 deletions
+17 -6
View File
@@ -66,6 +66,17 @@ func RegisterAll(s *Scheduler) {
Handler: payments.SweepStalePendingPayments,
})
// Cancels terminal (card-machine) checkouts still pending at Square after
// an hour — a never-polled checkout would otherwise sit live indefinitely
// and complete into an invisible, untracked charge.
s.Register(Job{
Name: "sweep-stale-terminal-checkouts",
Schedule: "*/15 * * * *",
Timeout: 60 * time.Second,
Concurrency: 1,
Handler: payments.SweepStaleTerminalCheckouts,
})
// === MID FREQUENCY — every minute (progressive rate limiter was on 30s) ===
s.Register(Job{
@@ -156,7 +167,7 @@ func RegisterAll(s *Scheduler) {
s.Register(Job{
Name: "apply-default-hours",
Schedule: "5 0 * * *", // Daily at 00:05 — after midnight to avoid race
Schedule: "5 0 * * *", // Daily at 00:05 — after midnight to avoid race
Timeout: 30 * time.Second,
Concurrency: 1,
Handler: scheduling.ApplyScheduledDefaultHours,
@@ -166,7 +177,7 @@ func RegisterAll(s *Scheduler) {
s.Register(Job{
Name: "notify-unpaid-1-week",
Schedule: "0 7 * * *", // Daily at 7am — end of business day + 7 days
Schedule: "0 7 * * *", // Daily at 7am — end of business day + 7 days
Timeout: 2 * time.Minute,
Concurrency: 1,
Handler: scheduling.NotifyUnpaidOneWeek,
@@ -174,7 +185,7 @@ func RegisterAll(s *Scheduler) {
s.Register(Job{
Name: "notify-unpaid-1-month",
Schedule: "30 7 * * *", // Daily at 7:30am (staggered from notify-unpaid-1-week)
Schedule: "30 7 * * *", // Daily at 7:30am (staggered from notify-unpaid-1-week)
Timeout: 2 * time.Minute,
Concurrency: 1,
Handler: scheduling.NotifyUnpaidOneMonth,
@@ -182,7 +193,7 @@ func RegisterAll(s *Scheduler) {
s.Register(Job{
Name: "transition-discount-campaigns",
Schedule: "0 * * * *", // Hourly
Schedule: "0 * * * *", // Hourly
Timeout: 30 * time.Second,
Concurrency: 1,
Handler: scheduling.TransitionDiscountCampaigns,
@@ -190,7 +201,7 @@ func RegisterAll(s *Scheduler) {
s.Register(Job{
Name: "cleanup-verification-codes",
Schedule: "0 2 * * *", // Daily at 2am
Schedule: "0 2 * * *", // Daily at 2am
Timeout: 30 * time.Second,
Concurrency: 1,
Handler: scheduling.CleanupExpiredVerificationCodes,
@@ -198,7 +209,7 @@ func RegisterAll(s *Scheduler) {
s.Register(Job{
Name: "cleanup-refresh-tokens",
Schedule: "0 2 * * *", // Daily at 2am
Schedule: "0 2 * * *", // Daily at 2am
Timeout: 30 * time.Second,
Concurrency: 1,
Handler: scheduling.CleanupExpiredRefreshTokens,
+4 -3
View File
@@ -413,8 +413,8 @@ func TestRegisterAll_RegistersExpectedJobs(t *testing.T) {
s := New()
RegisterAll(s)
if got := len(s.registry); got != 22 {
t.Fatalf("RegisterAll() registered %d jobs, want 22", got)
if got := len(s.registry); got != 23 {
t.Fatalf("RegisterAll() registered %d jobs, want 23", got)
}
registered := make(map[string]Job, len(s.registry))
@@ -474,6 +474,7 @@ func expectedJobNames() map[string]bool {
"cleanup-gdpr-export-cache": true,
"sweep-pending-square-refunds": true,
"sweep-stale-pending-payments": true,
"sweep-stale-terminal-checkouts": true,
"cleanup-progressive-rate-limiter": true,
"cleanup-expired-loyalty-redemptions": true,
"cleanup-old-idempotency-keys": true,
@@ -511,7 +512,7 @@ func TestRegisterAll_NoDuplicateCronExpressions(t *testing.T) {
// disjoint tables (no contention risk).
knownGroupings := map[int]bool{
5: true, // */5 * * * * — 5 cleanup jobs (incl. sweep-pending-square-refunds), different domains
// 0 * * * * — 5 hourly cleanup jobs, different tables
// 0 * * * * — 5 hourly cleanup jobs, different tables
2: true, // 0 2 * * * — 2 daily cleanup jobs, different tables
}